#!/bin/dash #version="$(psql "$DB_URL" -c "SELECT version FROM hectic.version WHERE name = 'migrator';")" # error codes # 1 - generic error # 2 - ambiguous, when you try to use something that cannot be used in same time # 3 - missing required argument / variable # 4 - # 5 - provided table that not exists # 9 - argument or command not found # 13 - program bug / unexpected system / database incompatibles # 127 - command not found (dependency) set -eu VERSION='0.0.1' MIGRATION_DIR="${MIGRATION_DIR:-migration}" : "${DB_URL:=DB_URL}" REMAINING_ARS= : "${PAGER:=cat}" quote() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"; } # cat filename | sha256sum() # sha256sum(filename) sha256sum() { local file file="${1:-'-'}" cksum --algorithm=sha256 --untagged "$file" | awk '{printf $1}' } # detect_db_type() # Returns: "postgresql" or "sqlite" detect_db_type() { if ! [ "${DB_URL+x}" ]; then log error "no ${WHITE}DB_URL${NC} or ${WHITE}--db-url${NC} specified" exit 3 fi case "$DB_URL" in postgresql://*|postgres://*) printf 'postgresql' ;; sqlite://*|*.db|*.sqlite|*.sqlite3) printf 'sqlite' ;; *) log error "unsupported database URL format: ${WHITE}$DB_URL${NC}" log error "supported formats: postgresql://... or sqlite://... or *.db" exit 3 ;; esac } # get_sqlite_path() get_sqlite_path() { case "$DB_URL" in sqlite://*) printf '%s' "$DB_URL" | sed 's|^sqlite://||' ;; *) printf '%s' "$DB_URL" ;; esac } # db_exec(sql) db_exec() { local sql="$1" local db_type db_type=$(detect_db_type) case "$db_type" in postgresql) local psql_args psql_args="$(form_psql_args)" # shellcheck disable=SC2086 printf '%s' "$sql" | psql $psql_args "$DB_URL" ;; sqlite) local db_path db_path=$(get_sqlite_path) # NOTE: Use -batch for non-interactive execution printf '%s' "$sql" | sqlite3 -batch "$db_path" ;; esac } # db_query(sql) db_query() { local sql="$1" local db_type db_type=$(detect_db_type) case "$db_type" in postgresql) psql "$DB_URL" --no-align --tuples-only --quiet --command "$sql" | awk NF ;; sqlite) local db_path db_path=$(get_sqlite_path) # Use -noheader -list for clean output (one value per line, no formatting) sqlite3 -bail -noheader -list "$db_path" "$sql" | awk NF ;; esac } # db_exec_file(file_path) db_exec_file() { local file_path="$1" local db_type db_type=$(detect_db_type) case "$db_type" in postgresql) local psql_args escaped_path psql_args="$(form_psql_args)" escaped_path=$(printf '%s' "$file_path" | sed "s/'/''/g") # shellcheck disable=SC2086 psql $psql_args "$DB_URL" </up.sql (single-file) # 2. MIGRATION_DIR//up/entrypoint.sql (multi-file) # Returns the resolved path on stdout, exits with error if neither found. resolve_migration_path() { local name="$1" direction="$2" local single_file="$MIGRATION_DIR/$name/${direction}.sql" local multi_file="$MIGRATION_DIR/$name/${direction}/entrypoint.sql" if [ -f "$single_file" ]; then printf '%s' "$single_file" elif [ -f "$multi_file" ]; then printf '%s' "$multi_file" else log error "migration ${direction} not found for ${WHITE}$name${NC}" log error "expected either ${WHITE}$single_file${NC} or ${WHITE}$multi_file${NC}" exit 1 fi } # has_migration_direction(migration_name, direction) # direction: "up" or "down" # Returns 0 if the migration has the given direction, 1 otherwise. has_migration_direction() { local name="$1" direction="$2" local single_file="$MIGRATION_DIR/$name/${direction}.sql" local multi_file="$MIGRATION_DIR/$name/${direction}/entrypoint.sql" [ -f "$single_file" ] || [ -f "$multi_file" ] } # index_of(array, name) index_of() { local list name m i=1 list=$1 name=$2 [ -z "$name" ] && return 1 # no subshell, no pipeline while IFS= read -r m; do [ "$m" = "$name" ] && { printf '%s\n' "$i"; return 0; } i=$((i+1)) done < pass through *) MIGRATOR_REMAINING_ARS="$MIGRATOR_REMAINING_ARS $(quote "$1")"; shift ;; esac done log debug "migrate REMAINING_ARGS: $WHITE$MIGRATOR_REMAINING_ARS" [ "${FORCE+x}" ] && { log error "migrate --force not implemented" exit 1 } init fs_migrations=$(migration_list) db_type=$(detect_db_type) case "$db_type" in postgresql) db_migrations=$(db_query "SELECT name FROM hectic.migration ORDER BY name ASC") ;; sqlite) db_migrations=$(db_query "SELECT name FROM hectic_migration ORDER BY name ASC") ;; esac log debug "db mig: $db_migrations" db_mig_count=$(printf '%s' "$db_migrations" | wc -l) log debug "mig count: $db_mig_count" # Log migration lists for debugging fs_mig_count=$(printf '%s' "$fs_migrations" | wc -l) log info "Filesystem migrations found: ${WHITE}$fs_mig_count" log info "Database migrations applied: ${WHITE}$db_mig_count" # Check if the DB migrations form a proper prefix of disk migrations # (meaning all DB-applied migration filenames should appear in the same order at the start). i=0 for db_migration in $db_migrations; do fs_migration=$(printf '%s' "$fs_migrations" | sed -n "$((i+1))p") log debug "Checking migration $((i+1)): DB=${WHITE}$db_migration${NC} vs FS=${WHITE}$fs_migration" if [ -z "$fs_migration" ] || [ "$fs_migration" != "$db_migration" ]; then if ! [ "${FORCE+x}" ]; then log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log error "${RED}Migration history mismatch detected!${NC}" log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log error "" log error "Position: Migration #$((i+1))" log error "Database has: ${WHITE}$db_migration" log error "Filesystem has: ${WHITE}$fs_migration" log error "" log error "Full filesystem migrations (in order):" j=1 printf '%s\n' "$fs_migrations" | while IFS= read -r m; do log error " $j. ${CYAN}$m" j=$((j+1)) done log error "" log error "Full database migrations (in order):" j=1 printf '%s\n' "$db_migrations" | while IFS= read -r m; do log error " $j. ${CYAN}$m" j=$((j+1)) done log error "" log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log error "This usually means:" log error " • Migration files were removed or renamed" log error " • Migrations were applied out of order" log error " • Database and codebase are from different versions" log error "" log error "${YELLOW}To proceed anyway, use: ${WHITE}--force${NC}${YELLOW}!${NC}" log error "${YELLOW}Warning: This may cause data inconsistencies!${NC}" log error "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 2 else log notice "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" log notice "${YELLOW}Migration history mismatch detected but ${WHITE}--force${NC}${YELLOW} specified${NC}" log notice "Position: Migration #$((i+1))" log notice "Database has: ${WHITE}$db_migration" log notice "Filesystem has: ${WHITE}$fs_migration" log notice "${YELLOW}Proceeding with migration despite mismatch...${NC}" log notice "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" break fi fi i=$((i+1)) done log info "Migration history validation: ${GREEN}OK${NC} (${WHITE}$i${NC} migrations match)" eval "set -- $MIGRATOR_REMAINING_ARS" target_migration="$("migrate_$MIGRATE_SUBCOMMAND" "$@")" if [ -z "$db_migrations" ]; then log info "it'll firs migration" current_idx=0 else current_migration=$(printf '%s\n' "$db_migrations" | tail -n1) current_idx=$(index_of "$fs_migrations" "$current_migration") fi log debug "[$WHITE$fs_migrations$NC]" log debug "$target_migration" if [ -z "$target_migration" ]; then target_idx=0 else target_idx=$(index_of "$fs_migrations" "$target_migration") fi log debug "indexes $WHITE$current_idx$NC $WHITE${target_idx}" if [ "$target_idx" -eq "$current_idx" ]; then if [ "$target_idx" -eq 0 ]; then log notice "database already at clean state (no migrations)" else log notice "database already at ${WHITE}$target_migration${NC}" fi exit 0 fi # Apply migrations psql_args="$(form_psql_args)" if [ "$target_idx" -gt "$current_idx" ]; then # Migrate UP log info "migrating up from index $current_idx to $target_idx" i=$((current_idx + 1)) while [ "$i" -le "$target_idx" ]; do fs_migration=$(printf '%s' "$fs_migrations" | sed -n "${i}p") escaped_name=$(printf '%s' "$fs_migration" | sed "s/'/''/g") mig_path=$(resolve_migration_path "$fs_migration" "up") escaped_path=$(printf '%s' "$mig_path" | sed "s/'/''/g") mig_hash=$(sha256sum "$mig_path") log info "applying migration ${WHITE}$fs_migration${NC} (up)" case "$db_type" in postgresql) local psql_args psql_args="$(form_psql_args)" # shellcheck disable=SC2086 if ! psql $psql_args "$DB_URL" < "$file_path" log notice "created migration: ${WHITE}${file_path}${NC}" } fetch() { while [ $# -gt 0 ]; do case $1 in --db-url|-u) # shellcheck disable=SC2034 if ! [ "${DB_URL+x}" ]; then DB_URL=$2 fi shift 2 ;; esac done error_handler_no_db_url } list() { while [ $# -gt 0 ]; do case $1 in --raw|-r) RAW=1 shift ;; --*|-*) log error "init argument $1 does not exists" exit 9 ;; *) log error "init subcommand $1 does not exists" exit 9 ;; esac done [ "${RAW+x}" ] && { migration_list exit } migration_list | while read -r name; do has_up=true has_down=true if ! has_migration_direction "$name" "up"; then has_up=false fi if ! has_migration_direction "$name" "down"; then has_down=false fi if [ "$has_up" = false ] || [ "$has_down" = false ]; then missing="" [ "$has_up" = false ] && missing="up.sql" if [ "$has_down" = false ]; then if [ -n "$missing" ]; then missing="$missing down.sql" else missing="down.sql" fi fi echo "$name: missing $missing" else echo "$name" fi done } generate_word() { C="b c d f g h j k l m n p r s t v w z" V="a e i o u" N=${N:-5} w= for i in $(seq 3); do c=$(echo "$C" | tr ' ' '\n' | shuf -n1) v=$(echo "$V" | tr ' ' '\n' | shuf -n1) w="${w}${c}${v}" done printf '%s' "$w" } check_db_dependencies() { [ "${DB_URL+x}" ] || return 0 # Skip if no DB_URL yet db_type=$(detect_db_type) case "$db_type" in postgresql) if ! command -v psql >/dev/null; then log error "Required tool (psql) is not installed." log error "PostgreSQL client tools are required for postgresql:// URLs" exit 127 fi ;; sqlite) if ! command -v sqlite3 >/dev/null; then log error "Required tool (sqlite3) is not installed." log error "SQLite3 client is required for sqlite:// URLs" exit 127 fi ;; esac } if ! [ "${AS_LIBRARY+x}" ]; then # Show help if no arguments [ $# -eq 0 ] && { help; exit 0; } while [ $# -gt 0 ]; do log debug "arg: $1" case $1 in --version|-V) printf 'migrator version %s\n' "$VERSION" exit 0 ;; help|--help|-h) help exit 0 ;; migrate|create|fetch|list|init) [ "${SUBCOMMAND+x}" ] && { log error "ambiguous subcommand, decide ${WHITE}$SUBCOMMAND ${NC}or ${WHITE}$1"; exit 2; } SUBCOMMAND=$1 shift ;; --migration-dir|-d) MIGRATION_DIR=$2 shift 2 ;; --inherits) INHERITS_LIST="${INHERITS_LIST+$INHERITS_LIST\"}$2" shift 2 ;; --*|-*) REMAINING_ARS="$REMAINING_ARS $(quote "$1")"; shift ;; # unknown global -> pass through *) REMAINING_ARS="$REMAINING_ARS $(quote "$1")"; shift ;; esac done [ "${INHERITS_LIST+x}" ] && INHERITS_LIST="$(printf '%s' "$INHERITS_LIST" | sed -E 's/"/,/g; s/([^,]+)/"\1"/g')" [ "${SUBCOMMAND+x}" ] || { log error "no subcommand specified. Use 'migrator help' for usage information."; exit 1; } eval "set -- $REMAINING_ARS" "$SUBCOMMAND" "$@" fi