# shellcheck shell=dash # shellcheck disable=SC3043 : "${REMAINING_ARGS:=}" : "${DEFAULT_BACKUP_PATH:=${LOCAL_DIR:-$PWD}/focus/postgresql-backup}" : "${DEFAULT_NORMALIZED_BACKUP_PATH:=${LOCAL_DIR:-$PWD}/focus/postgresql-backup-normalized}" : "${SCRIPT_NAME:=$(basename "$0")}" SCRIPT_NAME=${SCRIPT_NAME%%.sh} : "${PATCH_SQL:=${PROFILE_DIR:-${LOCAL_DIR:-$PWD}}/test-data.sql}" if [ "${PG_LOG_PATH+x}" ]; then : "${PATCH_LOG:=${PG_LOG_PATH}/patch.stdout.log}" : "${HYDRATE_LOG:=${PG_LOG_PATH}/hydrate.stdout.log}" fi : "${DATABASE_DIR:=${LOCAL_DIR:-$PWD}/db}" : "${MIGRATION_DIR:=$DATABASE_DIR/migration}" : "${DATABASE_SOURCE:=$DATABASE_DIR/src}" : "${HYDRATE_ENTRYPOINT:=entrypoint.sql}" HYDRATE_ENTRYPOINT="${DATABASE_SOURCE}/${HYDRATE_ENTRYPOINT}" : "${ENVIRONMENT:=}" pager_or_cat_init : "${DB_URL:=${PGURL:-}}" form_psql_args() { local psql_args="$PGURL -v ON_ERROR_STOP=1" #for var in ${VARIABLE_LIST:-}; do # psql_args="$psql_args -v $var" #done printf '%s' "$psql_args" } psql_logged() { local log_file="$1" shift if [ -n "$log_file" ]; then psql "$@" > "$log_file" else psql "$@" fi } db_exec() { local sql="$1" # shellcheck disable=SC2046 printf '%s' "$sql" | psql $(form_psql_args) } todo() { log panic "TODO" exit 1 } log_pager() { # shellcheck disable=SC2068 nvim -R \ -u NONE \ -c 'nnoremap q :qa!' \ -c 'runtime! plugin/*.vim' \ -c 'set conceallevel=3' \ $@ \ - } help_log() { # shellcheck disable=SC2059 printf "$(cat <|] View PostgreSQL logs for current PG_WORKING_DIR. ${BGREEN}Modes:${NC} ${BCYAN}log$NC Interactive pager when attached to TTY, non-interactive concat when piped/redirected ${BCYAN}log list$NC List available log files with numeric indexes ${BCYAN}log $NC Open/print one file by numeric index from list ${BCYAN}log $NC Open/print one file by exact name or unique substring ${BGREEN}Examples:${NC} $SCRIPT_NAME log $SCRIPT_NAME log list $SCRIPT_NAME log 1 $SCRIPT_NAME log postgresql-2026-04-21 EOF )" | "$PAGER_OR_CAT" } db_log_list_files() { log_dir="$1" for log_file in "$log_dir"/*; do [ -f "$log_file" ] || continue printf '%s\n' "$log_file" done } db_log_print_list() { log_dir="$1" idx=1 found=0 for log_file in "$log_dir"/*; do [ -f "$log_file" ] || continue found=1 printf '%2s %s\n' "$idx" "$(basename "$log_file")" idx=$((idx + 1)) done if [ "$found" -eq 0 ]; then log warn "no log files in $log_dir" fi } db_log_resolve_one() { log_dir="$1" selector="$2" if [ -f "$log_dir/$selector" ]; then printf '%s\n' "$log_dir/$selector" return 0 fi if [ -n "$selector" ] && [ "$selector" -eq "$selector" ] 2>/dev/null; then idx=1 for log_file in "$log_dir"/*; do [ -f "$log_file" ] || continue if [ "$idx" -eq "$selector" ]; then printf '%s\n' "$log_file" return 0 fi idx=$((idx + 1)) done return 1 fi matches= match_count=0 for log_file in "$log_dir"/*; do [ -f "$log_file" ] || continue case "$(basename "$log_file")" in *"$selector"*) match_count=$((match_count + 1)) matches="$matches$(printf '%s\n' "$log_file")" ;; esac done if [ "$match_count" -eq 1 ]; then printf '%s' "$matches" return 0 fi return 1 } help() { # shellcheck disable=SC2059 printf "$(cat < [OPTIONS] PostgreSQL development database management ${BGREEN}Global Options:${NC} $BCYAN-h$NC, $BCYAN--help$NC Show this help message $BCYAN-i$NC, $BCYAN--inherits$NC Parent table for hectic.migration Can be specified multiple times $BCYAN-u$NC, $BCYAN--url$NC PostgreSQL connection string (overrides PGURL) ${BGREEN}Database Subcommands:${NC} ${BCYAN}deploy ${CYAN}[OPTIONS]$NC Initialize and deploy PostgreSQL database Default action with full setup including hydrate and patch Options: $BCYAN--no-patch$NC Skip applying test-data.sql $BCYAN--no-hydrate$NC Skip building from source files ${BCYAN}init${NC} ${CYAN}[OPTIONS]$NC Initialize PostgreSQL cluster without hydrate or patch ${BCYAN}restore${NC} ${CYAN}[PATH]$NC Restore database from backup ${BCYAN}normalize-backup${NC} ${CYAN}[OPTIONS] [PATH]$NC Normalize a raw physical backup for local restore/diff ${BCYAN}backup${NC} Create database backup Creates compressed backup at $BBLACK$DEFAULT_BACKUP_PATH$NC ${BCYAN}patch${NC} ${CYAN}[OPTIONS]$NC Patch file: $BBLACK$PATCH_SQL$NC Apply patch to current database Uses patch file if exists Creates empty patch file if not found Options: $BCYAN--edit$NC, $BCYAN-e$NC Edit test-data.sql in \$EDITOR before applying ${BCYAN}hydrate${NC} ${CYAN}[OPTIONS]$NC Build database from source files Runs $BBLACK$HYDRATE_ENTRYPOINT$NC ${BCYAN}migrator${NC} ${CYAN}[OPTIONS]$NC Run database migrations Uses for production or pre production ${BCYAN}log${NC} ${CYAN}[ARG]$NC Show PostgreSQL logs ARG: ${BBLACK}list$NC, numeric index, or filename/substring No ARG: interactive on TTY, concat output when piped ${BCYAN}replay${NC} ${CYAN}[OPTIONS]$NC Restore database from point to point using SQL Useful for development after minor schema changes ${BRED}WIP$NC ${BCYAN}pull_staging${NC} Pull data from staging DB into test-data.sql Uses STAGING_* environment variables for connection and dump selection ${BCYAN}format${NC} Format database source files ${BRED}WIP$NC ${BCYAN}test${NC} Run SQL test suite (BEGIN/ROLLBACK, no side effects) Executes ${BBLACK}${DATABASE_DIR}/test/test.sql$NC ${BCYAN}diff${NC} ${CYAN}[OPTIONS] [PATH]$NC Compare backup+migrations vs current sources Shows schema differences between production and dev Creates isolated instances for safe comparison Subcommands: ${BCYAN}log$NC Show diff operation logs ${BCYAN}check${NC} ${CYAN}[OPTIONS]$NC Full deploy + cleanup in an isolated temporary cluster Does not affect your running development database Options: $BCYAN--no-patch$NC Skip applying test-data.sql $BCYAN--no-hydrate$NC Skip building from source files $BCYAN-m$NC Mock external API calls ${BCYAN}cleanup${NC} Stop the running PostgreSQL cluster and remove its working directory ${BGREEN}Environment Variables:${NC} ${BBLACK}PGURL$NC PostgreSQL connection string (auto-detected) ${BBLACK}PROFILE_DIR$NC Profile directory (auto-detected) ${BBLACK}DATABASE_SOURCE$NC Database source files directory (default $BBLACK$DATABASE_SOURCE$NC) ${BGREEN}Examples:${NC} ${BBLACK}# Basic operations$NC $SCRIPT_NAME deploy Deploy database with full setup $SCRIPT_NAME deploy --no-patch Deploy without test data $SCRIPT_NAME init Initialize PostgreSQL only $SCRIPT_NAME restore Restore from default backup $SCRIPT_NAME restore /path/to/backup Restore from specific path $SCRIPT_NAME normalize-backup /path/to/raw-backup $SCRIPT_NAME backup Create backup $SCRIPT_NAME log Show database logs $SCRIPT_NAME log list List available log files $SCRIPT_NAME log 1 Show one log file by index $SCRIPT_NAME log postgresql-2026-04-21 Show one log file by name pattern $SCRIPT_NAME patch Apply test data $SCRIPT_NAME patch --edit Edit test data and apply ${BBLACK}# Schema comparison$NC $SCRIPT_NAME diff Compare backup vs sources $SCRIPT_NAME diff -o diff.txt Save comparison to file $SCRIPT_NAME diff --with-data Include data in comparison ${BBLACK}# Isolated validation$NC $SCRIPT_NAME check Full deploy + cleanup in temp cluster $SCRIPT_NAME check -P Check without test data $SCRIPT_NAME check -H Check without hydrating schema EOF )" | "$PAGER_OR_CAT" } help_patch() { # shellcheck disable=SC2059 printf "$(cat <$NC Output backup directory Defaults to $BBLACK$DEFAULT_NORMALIZED_BACKUP_PATH$NC $BCYAN--role$NC ${CYAN}$NC Login role to ensure (default $BBLACK\$(id -un)$NC) $BCYAN--database$NC ${CYAN}$NC Database to ensure (default $BBLACK\${PG_DATABASE:-testdb}$NC) $BCYAN--admin-role$NC ${CYAN}$NC Role used for local maintenance Defaults to $BBLACK\${URI_USER:-postgres}$NC $BCYAN-h$NC, $BCYAN--help$NC Show this help message ${BGREEN}Files Created:${NC} - ${BBLACK}\`base.tar.gz\`$NC: Normalized database cluster backup ${BGREEN}Examples:${NC} $SCRIPT_NAME normalize-backup /path/to/raw-backup $SCRIPT_NAME normalize-backup --output focus/postgresql-backup-normalized EOF )" | "$PAGER_OR_CAT" } help_diff() { # shellcheck disable=SC2059 printf "$(cat <${NC} Comma-separated list of tables to diff Example: --tables users,orders,products $BCYAN--ignore-migration-fail${NC} Continue diff even if DB1 migrations fail $BCYAN-m${NC}, $BCYAN--mock${NC} Mock external API calls when hydrating DB2 Replaces HTTP-calling functions with stubs/test data $BCYAN-h${NC}, $BCYAN--help${NC} Show this help message ${BGREEN}Subcommands:${NC} ${BCYAN}log${NC} Show PostgreSQL logs from diff operation Displays logs from both DB1 and DB2 instances ${BGREEN}Process:${NC} 1. Create two isolated PostgreSQL instances (DB1 and DB2) 2. Restore DB1 from backup and apply all migrations 3. Hydrate DB2 from current source files 4. Dump both databases to SQL files 5. Compare dumps and present diff 6. Clean up temporary instances ${BGREEN}Environment Variables:${NC} ${BBLACK}DATABASE_SOURCE$NC Path to database source files ${BGREEN}Examples:${NC} $SCRIPT_NAME diff Compare using default backup $SCRIPT_NAME diff /path/to/backup Compare using specific backup $SCRIPT_NAME diff --tables users,orders Compare specific tables $SCRIPT_NAME diff --ignore-migration-fail Continue despite DB1 migration failure $SCRIPT_NAME diff -m Compare with external APIs mocked $SCRIPT_NAME diff log Show logs from diff operation ${BGREEN}Notes:${NC} - Does not affect your development database - Requires backup created with $BBLACK\`backup\`$NC subcommand EOF )" | "$PAGER_OR_CAT" } help_init() { # shellcheck disable=SC2059 printf "$(cat <&2 exit 3 fi if [ -z "${STAGING_DB_URL-}" ]; then printf 'STAGING_DB_URL is not set\n' >&2 exit 3 fi if [ -z "${STAGING_DUMP_TABLES-}" ]; then printf 'STAGING_DUMP_TABLES is not set\n' >&2 exit 3 fi if [ -z "${STAGING_DUMP_FLAGS-}" ]; then printf 'STAGING_DUMP_FLAGS is not set\n' >&2 exit 3 fi while [ $# -gt 0 ]; do case $1 in -h|--help) help_pull_staging exit 0 ;; --*|-*) log error "pull_staging argument $1 does not exist" exit 9 ;; *) log error "pull_staging: unexpected argument $1" exit 9 ;; esac done PG_DUMP_TABLE_ARGS='' for tbl in $STAGING_DUMP_TABLES; do PG_DUMP_TABLE_ARGS="$PG_DUMP_TABLE_ARGS -t $(quote "$tbl")" done log notice "pulling data from ${WHITE}${STAGING_SSH_HOST}${NC}" remote_dump_command="pg_dump $(quote "$STAGING_DB_URL") $STAGING_DUMP_FLAGS$PG_DUMP_TABLE_ARGS 2>/dev/null" # shellcheck disable=SC2029 STAGING_DUMP=$(ssh "$STAGING_SSH_HOST" "$remote_dump_command") || { log error "failed to dump staging database via SSH" exit 1 } if [ -z "$STAGING_DUMP" ]; then log error "staging dump returned empty output" exit 1 fi SEQ_RESETS='' for tbl in $STAGING_DUMP_TABLES; do escaped_tbl=$(printf '%s' "$tbl" | sed "s/'/''/g") SEQ_RESETS="$SEQ_RESETS SELECT setval(seq_name, COALESCE((SELECT MAX(\"id\") FROM $tbl), 1)) FROM (SELECT pg_get_serial_sequence('$escaped_tbl', 'id') AS seq_name) AS seq WHERE seq_name IS NOT NULL;" done mkdir -p "$(dirname "$PATCH_SQL")" touch "$PATCH_SQL" cat > "$PATCH_SQL" </dev/null || : } ___normalize_backup_cleanup() { local tmpdir="$1" local pgdata="$2" if [ -n "$pgdata" ] && [ -d "$pgdata" ]; then pg_ctl -D "$pgdata" -m fast -w stop >/dev/null 2>&1 || : fi if [ -n "$tmpdir" ]; then rm -rf "$tmpdir" fi } ___normalize_backup_refresh_collation() { local sockdir="$1" local port="$2" local admin_role="$3" local database_name="$4" psql -h "$sockdir" -p "$port" -U "$admin_role" -d postgres \ -v ON_ERROR_STOP=1 -c "ALTER DATABASE \"$database_name\" REFRESH COLLATION VERSION;" \ >/dev/null 2>&1 || : } subcommand_normalize_backup() { change_namespace 'db normalize-backup' NORMALIZE_INPUT_PATH="" NORMALIZE_OUTPUT_PATH="$DEFAULT_NORMALIZED_BACKUP_PATH" NORMALIZE_ROLE="$(id -un)" NORMALIZE_DATABASE="${PG_DATABASE:-testdb}" NORMALIZE_ADMIN_ROLE="postgres" NORMALIZE_PORT="${PG_PORT:-5432}" NORMALIZE_PRELOAD_LIBRARIES="${PG_SHARED_PRELOAD_LIBRARIES:-pg_cron}" NORMALIZE_DATABASE_EXPLICIT=0 while [ $# -gt 0 ]; do case $1 in -h|--help) help_normalize_backup exit 0 ;; -o|--output) if [ $# -lt 2 ]; then log error "normalize-backup: --output requires a path" exit 3 fi NORMALIZE_OUTPUT_PATH="$2" shift 2 ;; --role) if [ $# -lt 2 ]; then log error "normalize-backup: --role requires a name" exit 3 fi NORMALIZE_ROLE="$2" shift 2 ;; --database) if [ $# -lt 2 ]; then log error "normalize-backup: --database requires a name" exit 3 fi NORMALIZE_DATABASE="$2" NORMALIZE_DATABASE_EXPLICIT=1 shift 2 ;; --admin-role) if [ $# -lt 2 ]; then log error "normalize-backup: --admin-role requires a name" exit 3 fi NORMALIZE_ADMIN_ROLE="$2" shift 2 ;; --*|-*) log error "normalize-backup argument $1 does not exist" exit 9 ;; *) if [ -n "$NORMALIZE_INPUT_PATH" ]; then log error "normalize-backup: unexpected argument $1" exit 9 fi NORMALIZE_INPUT_PATH="$1" shift ;; esac done if [ -z "$NORMALIZE_INPUT_PATH" ]; then NORMALIZE_INPUT_PATH="$DEFAULT_BACKUP_PATH" fi if [ -z "$NORMALIZE_ROLE" ] || [ -z "$NORMALIZE_DATABASE" ] || [ -z "$NORMALIZE_ADMIN_ROLE" ]; then log error "normalize-backup: role, database, and admin role must be non-empty" exit 3 fi if [ ! -d "$NORMALIZE_INPUT_PATH" ]; then log error "normalize-backup: input backup directory not found: $NORMALIZE_INPUT_PATH" exit 3 fi if [ ! -f "$NORMALIZE_INPUT_PATH/base.tar.gz" ]; then log error "normalize-backup: missing $NORMALIZE_INPUT_PATH/base.tar.gz" exit 3 fi NORMALIZE_INPUT_ABS=$(realpath "$NORMALIZE_INPUT_PATH") NORMALIZE_OUTPUT_ABS=$(realpath -m "$NORMALIZE_OUTPUT_PATH") if [ "$NORMALIZE_INPUT_ABS" = "$NORMALIZE_OUTPUT_ABS" ]; then log error "normalize-backup: input and output paths must differ" exit 1 fi case "$NORMALIZE_OUTPUT_ABS/" in "$NORMALIZE_INPUT_ABS"/*) log error "normalize-backup: output path must not be inside input backup" exit 1 ;; esac case "$NORMALIZE_INPUT_ABS/" in "$NORMALIZE_OUTPUT_ABS"/*) log error "normalize-backup: input backup must not be inside output path" exit 1 ;; esac mkdir -p "$(dirname "$NORMALIZE_OUTPUT_ABS")" NORMALIZE_TMPDIR=$(mktemp -d) NORMALIZE_PGDATA="$NORMALIZE_TMPDIR/data" NORMALIZE_SOCKDIR="$NORMALIZE_TMPDIR/sock" NORMALIZE_LOG="$NORMALIZE_TMPDIR/postgres.log" trap '___normalize_backup_cleanup "$NORMALIZE_TMPDIR" "$NORMALIZE_PGDATA"' EXIT INT HUP mkdir -m 700 "$NORMALIZE_PGDATA" mkdir -p "$NORMALIZE_SOCKDIR" "$NORMALIZE_PGDATA/pg_wal" log notice "extracting backup into temporary PGDATA" tar -xzf "$NORMALIZE_INPUT_ABS/base.tar.gz" -C "$NORMALIZE_PGDATA" if [ -f "$NORMALIZE_INPUT_ABS/pg_wal.tar.gz" ]; then tar -xzf "$NORMALIZE_INPUT_ABS/pg_wal.tar.gz" -C "$NORMALIZE_PGDATA/pg_wal" fi ___normalize_backup_remove_leftovers "$NORMALIZE_PGDATA" if [ ! -f "$NORMALIZE_PGDATA/postgresql.conf" ]; then cat > "$NORMALIZE_PGDATA/postgresql.conf" < "$NORMALIZE_PGDATA/pg_hba.conf" < "$NORMALIZE_PGDATA/pg_ident.conf" cat > "$NORMALIZE_PGDATA/postgresql.auto.conf" < "$NORMALIZE_LOG" 2>&1 || { log error "normalize-backup: failed to start temporary cluster; see $NORMALIZE_LOG" exit 1 } ___normalize_backup_refresh_collation "$NORMALIZE_SOCKDIR" "$NORMALIZE_PORT" "$NORMALIZE_ADMIN_ROLE" postgres ___normalize_backup_refresh_collation "$NORMALIZE_SOCKDIR" "$NORMALIZE_PORT" "$NORMALIZE_ADMIN_ROLE" template1 ___normalize_backup_refresh_collation "$NORMALIZE_SOCKDIR" "$NORMALIZE_PORT" "$NORMALIZE_ADMIN_ROLE" template0 if [ "$NORMALIZE_DATABASE_EXPLICIT" -eq 0 ]; then detected_database=$(psql -h "$NORMALIZE_SOCKDIR" -p "$NORMALIZE_PORT" -U "$NORMALIZE_ADMIN_ROLE" -d postgres \ -tAc "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate AND datname <> 'postgres' ORDER BY oid LIMIT 1" 2>/dev/null | sed '/^$/d' | head -n 1) if [ -n "$detected_database" ]; then NORMALIZE_DATABASE="$detected_database" fi fi NORMALIZE_ROLE_SQL=$(___sql_literal "$NORMALIZE_ROLE") psql -h "$NORMALIZE_SOCKDIR" -p "$NORMALIZE_PORT" -U "$NORMALIZE_ADMIN_ROLE" -d postgres \ -v ON_ERROR_STOP=1 -c "DO \$\$ DECLARE v_role text := $NORMALIZE_ROLE_SQL; BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = v_role) THEN EXECUTE format('CREATE ROLE %I LOGIN SUPERUSER', v_role); ELSE EXECUTE format('ALTER ROLE %I LOGIN SUPERUSER', v_role); END IF; END \$\$;" NORMALIZE_DATABASE_SQL=$(___sql_literal "$NORMALIZE_DATABASE") NORMALIZE_DATABASE_EXISTS=$(psql -h "$NORMALIZE_SOCKDIR" -p "$NORMALIZE_PORT" -U "$NORMALIZE_ADMIN_ROLE" -d postgres \ -tAc "SELECT 1 FROM pg_database WHERE datname = $NORMALIZE_DATABASE_SQL" 2>/dev/null || true) if [ "$NORMALIZE_DATABASE_EXISTS" != "1" ]; then createdb -h "$NORMALIZE_SOCKDIR" -p "$NORMALIZE_PORT" -U "$NORMALIZE_ADMIN_ROLE" \ -O "$NORMALIZE_ROLE" "$NORMALIZE_DATABASE" fi ___normalize_backup_refresh_collation "$NORMALIZE_SOCKDIR" "$NORMALIZE_PORT" "$NORMALIZE_ADMIN_ROLE" "$NORMALIZE_DATABASE" log notice "stopping temporary local cluster" pg_ctl -D "$NORMALIZE_PGDATA" -m fast -w stop >> "$NORMALIZE_LOG" 2>&1 ___normalize_backup_remove_leftovers "$NORMALIZE_PGDATA" log notice "writing normalized backup to $WHITE$NORMALIZE_OUTPUT_ABS$NC" rm -rf "$NORMALIZE_OUTPUT_ABS" mkdir -p "$NORMALIZE_OUTPUT_ABS" tar -czf "$NORMALIZE_OUTPUT_ABS/base.tar.gz" -C "$NORMALIZE_PGDATA" . printf '%s\n' "$NORMALIZE_DATABASE" > "$NORMALIZE_OUTPUT_ABS/database_name" ___normalize_backup_cleanup "$NORMALIZE_TMPDIR" "" trap - EXIT INT HUP restore_namespace } subcommand_log() { change_namespace 'db log' : "${PG_WORKING_DIR:="$LOCAL_DIR/focus/postgresql"}" LOG_DIR="${PG_WORKING_DIR}/data/log" if [ ! -d "$LOG_DIR" ]; then log error "log directory not found: $LOG_DIR" exit 1 fi while [ $# -gt 0 ]; do case $1 in -h|--help) help_log exit 0 ;; --*|-*) log error "log argument $1 does not exist" exit 9 ;; *) if [ "$1" = 'list' ]; then db_log_print_list "$LOG_DIR" restore_namespace return 0 fi resolved_file="$(db_log_resolve_one "$LOG_DIR" "$1" || true)" if [ -z "$resolved_file" ]; then log error "log file not found or ambiguous: $1" log info "use '$SCRIPT_NAME log list' to inspect available files" exit 1 fi if [ -t 1 ]; then log_pager "$resolved_file" else cat "$resolved_file" fi restore_namespace return 0 ;; esac done if [ -t 1 ]; then LOG_HELP="${LOCAL_DIR}/focus/database-log-help" # shellcheck disable=SC2059 printf "$(cat <<'EOF' press `q` - exit from logs use `gt` `gT` to navigate through different log files EOF )" > "$LOG_HELP" # shellcheck disable=SC2046 set -- $(db_log_list_files "$LOG_DIR") if [ "$#" -eq 0 ]; then log warn "no log files in $LOG_DIR" restore_namespace return 0 fi if [ "$#" -gt 1 ]; then log_pager -p "$LOG_HELP" "$@" else log_pager "$1" fi else found=0 for log_file in "$LOG_DIR"/*; do [ -f "$log_file" ] || continue found=1 cat "$log_file" done if [ "$found" -eq 0 ]; then log warn "no log files in $LOG_DIR" fi fi restore_namespace } subcommand_replay() { change_namespace 'db replay' todo } # shellcheck disable=SC2120 subcommand_hydrate() { change_namespace 'db hydrate' while [ $# -gt 0 ]; do case $1 in -h|--help) help_hydrate exit 0 ;; -H|--no-hook) HYDRATE_NO_HOOK=1 shift ;; -m|--mock) HYDRATE_USE_MOCK=1 shift ;; --*|-*) log error "hydrate argument $1 does not exist" exit 9 ;; *) log error "hydrate subcommand $1 does not exist" exit 9 ;; esac done if [ ! "${HYDRATE_NO_HOOK+x}" ]; then log info "hectic bundle hook" # shellcheck disable=SC2059 printf "${BBLACK}" dotenv_content="${HECTIC_DOTENV_CONTENT:-}" if [ -n "$dotenv_content" ]; then : elif [ -n "${HECTIC_DOTENV_FILE:-}" ] && [ -r "$HECTIC_DOTENV_FILE" ]; then dotenv_content="$(cat "$HECTIC_DOTENV_FILE")" elif [ -n "${ENVIRONMENT:-}" ] && [ -r "${LOCAL_DIR}/.env.${ENVIRONMENT}" ]; then dotenv_content="$(cat "${LOCAL_DIR}/.env.${ENVIRONMENT}")" fi apply_hectic_bundle "$PGURL" "$dotenv_content" # shellcheck disable=SC2059 printf "${NC}" else log info "skipping hectic bundle hook" fi local mock_arg="" if [ "${HYDRATE_USE_MOCK+x}" ]; then log info "mock mode enabled — external API calls will be stubbed" mock_arg="-v USE_MOCK=1" fi log notice "hydrate database sources" # shellcheck disable=SC2059 printf "${BBLACK}" # shellcheck disable=SC2046 # shellcheck disable=SC2086 if psql_logged "${HYDRATE_LOG:-}" $(form_psql_args) $mock_arg \ -f "$HYDRATE_ENTRYPOINT"; then log notice "hydrating succes" else log error "hydrate error, check $WHITE${HYDRATE_LOG:-stdout}$NC for more" exit 1 fi restore_namespace } subcommand_migrator() { change_namespace 'db migrator' db_url="${DB_URL:-$PGURL}" env MIGRATION_DIR="$MIGRATION_DIR" DB_URL="$db_url" migrator "$@" migrator_exit_code="$?" restore_namespace return "$migrator_exit_code" } # ___parse_deploy_flags -- shared option parser for deploy/check # Sets: DEPLOY_NO_PATCH, DEPLOY_NO_HYDRATE, HYDRATE_USE_MOCK, DEPLOY_REUSE # Returns remaining unparsed args via REMAINING_ARGS (caller resets first) # Caller must supply a CALLER name for error messages (first positional arg). ___parse_deploy_flags() { _caller="${1:?___parse_deploy_flags: caller name required}"; shift while [ $# -gt 0 ]; do case $1 in -P|--no-patch) DEPLOY_NO_PATCH=1 shift ;; -H|--no-hydrate) DEPLOY_NO_HYDRATE=1 shift ;; -m|--mock) HYDRATE_USE_MOCK=1 shift ;; --reuse) DEPLOY_NO_PATCH=1 DEPLOY_NO_HYDRATE=1 DEPLOY_REUSE=1 shift ;; *) log error "$_caller: unknown argument $1" exit 9 ;; esac done } # ___run_deploy_flow -- shared hydrate+patch execution for deploy/check ___run_deploy_flow() { if [ ! "${DEPLOY_NO_HYDRATE+x}" ]; then subcommand_hydrate fi if [ ! "${DEPLOY_NO_PATCH+x}" ]; then subcommand_patch fi } subcommand_init() { : "${PG_WORKING_DIR:=${LOCAL_DIR}/focus/postgresql}" # - to set postgresql server # data & sock directory change_namespace 'db init' unset DEPLOY_REUSE while [ $# -gt 0 ]; do case $1 in -h|--help) help_init exit 0 ;; --reuse) DEPLOY_REUSE=1 shift ;; --*|-*) log error "init argument $1 does not exist" exit 9 ;; *) log error "init: unexpected argument $1" exit 9 ;; esac done { [ "${DEPLOY_REUSE+x}" ] && export PG_REUSE postgres-init } restore_namespace } subcommand_deploy() { : "${PG_WORKING_DIR:="$LOCAL_DIR/focus/postgresql"}" # - to set postgresql server # data & sock directory change_namespace 'db deploy' unset DEPLOY_NO_PATCH DEPLOY_NO_HYDRATE DEPLOY_REUSE DEPLOY_CLEANUP _deploy_extra= while [ $# -gt 0 ]; do case $1 in -h|--help) help_deploy exit 0 ;; -c|--cleanup) DEPLOY_CLEANUP=1 shift ;; *) _deploy_extra="$_deploy_extra $(quote "$1")" shift ;; esac done [ "$_deploy_extra" = '' ] || eval "___parse_deploy_flags 'deploy' $_deploy_extra" { [ "${DEPLOY_REUSE+x}" ] && export PG_REUSE postgres-init } ___run_deploy_flow if [ "${DEPLOY_CLEANUP+x}" ]; then log info "cleanup: stopping postgresql" postgres-cleanup fi restore_namespace } # shellcheck disable=SC2120 subcommand_patch() { change_namespace 'db patch' while [ $# -gt 0 ]; do case $1 in -e|--edit) PATCH_EDIT=1 shift ;; -h|--help) help_patch exit 0 ;; --*|-*) log error "patch argument $1 does not exists" exit 9 ;; *) log error "patch subcommand $1 does not exists" exit 9 ;; esac done mkdir -p "$(dirname "$PATCH_SQL")" touch "$PATCH_SQL" if [ "${PATCH_EDIT+x}" ]; then log notice "opening $WHITE$PATCH_SQL$NC in \$EDITOR" "${EDITOR:-vi}" "$PATCH_SQL" exit 0 fi log notice "retoring $WHITE$PATCH_SQL$NC in database" # shellcheck disable=SC2059 printf "${BBLACK}" # shellcheck disable=SC2046 if psql_logged "${PATCH_LOG:-}" $(form_psql_args) -f "$PATCH_SQL"; then log notice "restoring succes" else log error "error, check $WHITE${PATCH_LOG:-stdout}$NC for more" exit 1 fi restore_namespace } ___diff_dump_schema() { local socket_dir="$1" local port="$2" local output_file="$3" local tables="${4:-}" local database_name="${PG_DATABASE:-testdb}" log info "dumping schema to $WHITE$output_file$NC" if [ -n "$tables" ]; then # Dump specific tables with data local table_args="" old_IFS="$IFS" IFS=',' for table in $tables; do table_args="$table_args -t $table" done IFS="$old_IFS" # shellcheck disable=SC2086 pg_dump -h "$socket_dir" -p "$port" "$database_name" \ --schema-only --no-owner --no-privileges \ $table_args > "$output_file" 2>/dev/null # shellcheck disable=SC2086 pg_dump -h "$socket_dir" -p "$port" "$database_name" \ --data-only --no-owner --no-privileges \ $table_args >> "$output_file" 2>/dev/null else # Schema only pg_dump -h "$socket_dir" -p "$port" "$database_name" \ --schema-only --no-owner --no-privileges \ > "$output_file" 2>/dev/null fi } ___diff_immutable_tables() { local socket_dir="$1" local port="$2" local database_name="${PG_DATABASE:-testdb}" psql -h "$socket_dir" -p "$port" -d "$database_name" -tAv ON_ERROR_STOP=1 -c "$(cat <<'SQL' SELECT n.nspname || '.' || c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE i.inhparent = 'hectic.immutable'::regclass AND c.relkind = 'r' ORDER BY 1 SQL )" 2>/dev/null } ___diff_immutable_data() { local sock1="$1" local port1="$2" local sock2="$3" local port2="$4" local out_file="$5" local database_name="${PG_DATABASE:-testdb}" if ! psql -h "$sock1" -p "$port1" -d "$database_name" -tAc \ "SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='hectic' AND c.relname='immutable';" \ >/dev/null 2>&1 then return 0 fi local tables1 tables2 tables tables1=$(___diff_immutable_tables "$sock1" "$port1" || true) tables2=$(___diff_immutable_tables "$sock2" "$port2" || true) tables=$(printf '%s\n%s\n' "$tables1" "$tables2" | sort -u | sed '/^$/d') if [ -z "$tables" ]; then return 0 fi log notice "diffing data of tables inheriting hectic.immutable" printf '\n--- IMMUTABLE TABLE DATA ---\n' >> "$out_file" local data1 data2 differs=0 data1=$(mktemp) data2=$(mktemp) trap 'rm -f "$data1" "$data2"' EXIT INT HUP for tbl in $tables; do log info " $tbl" : > "$data1" : > "$data2" pg_dump -h "$sock1" -p "$port1" "$database_name" \ --data-only --no-owner --no-privileges --column-inserts -t "$tbl" \ > "$data1" 2>/dev/null || : pg_dump -h "$sock2" -p "$port2" "$database_name" \ --data-only --no-owner --no-privileges --column-inserts -t "$tbl" \ > "$data2" 2>/dev/null || : { printf '\n=== %s ===\n' "$tbl" if diff --color=always -u "$data1" "$data2"; then printf '(no differences)\n' else differs=1 fi } >> "$out_file" done rm -f "$data1" "$data2" trap - EXIT INT HUP return $differs } help_check() { # shellcheck disable=SC2059 printf "$(cat </dev/null 2>&1 || true; pg_ctl -D "$DIFF_TMPDIR/pgdata2/data" stop -m fast >/dev/null 2>&1 || true;' EXIT INT HUP mkdir -p "$DIFF_PGDATA1" "$DIFF_PGDATA2" log info "DB1: $WHITE$DIFF_PGDATA1$NC" log info "DB2: $WHITE$DIFF_PGDATA2$NC" log notice "comparing backup vs sources" log info "backup: $WHITE$DIFF_BACKUP_PATH$NC" log info "sources: $WHITE$DATABASE_SOURCE$NC" log notice "provisioning ${WHITE}DB1$NC (backup + migrations)" PG_WORKING_DIR="$DIFF_PGDATA1" PG_LOG_PATH="$DIFF_PGDATA1" subcommand_restore "$DIFF_BACKUP_PATH" log info "applying migrations to ${WHITE}DB1$NC" subcommand_migrator migrate up all \ --db-url \ "$DIFF_PGURL1" \ || { if [ "$DIFF_IGNORE_MIGRATION_FAIL" = "1" ]; then log warn "migrations failed; continuing because --ignore-migration-fail is set" else log error "migrations failed" exit 1 fi } log notice "provisioning ${WHITE}DB2$NC (current sources)" log info "initializing ${WHITE}DB2$NC with postgres-init" PG_WORKING_DIR="$DIFF_PGDATA2" \ PG_DATABASE="$DIFF_DATABASE" \ PG_DISABLE_LOGGING=1 \ postgres-init || { log error "failed to initialize ${WHITE}DB2$NC" exit 1 } log info "hydrating ${WHITE}DB2$NC from sources" PGURL="$DIFF_PGURL2" \ subcommand_hydrate || { log error "hydration failed" exit 1 } subcommand_migrator init \ --db-url \ "$DIFF_PGURL2" \ || { log error "init migration failed" exit 1 } log notice "dumping schemas" DIFF_DUMP1="$DIFF_TMPDIR/target.sql" DIFF_DUMP2="$DIFF_TMPDIR/source.sql" ___diff_dump_schema "$DIFF_PGDATA1/sock" "5432" "$DIFF_DUMP1" "$DIFF_TABLES" ___diff_dump_schema "$DIFF_PGDATA2/sock" "5432" "$DIFF_DUMP2" "$DIFF_TABLES" # Optional: filter out cron tables if [ "$DIFF_NO_CRON" = "1" ]; then log info "filtering cron tables" grep -v "cron\." "$DIFF_DUMP1" > "$DIFF_DUMP1.filtered" 2>/dev/null || true grep -v "cron\." "$DIFF_DUMP2" > "$DIFF_DUMP2.filtered" 2>/dev/null || true mv "$DIFF_DUMP1.filtered" "$DIFF_DUMP1" mv "$DIFF_DUMP2.filtered" "$DIFF_DUMP2" fi log notice "generating diff" if diff --color=always -u "$DIFF_DUMP1" "$DIFF_DUMP2" \ > "$DIFF_TMPDIR/diff" then log notice "no schema differences found" schema_differs=0 else log notice "schema differences found" schema_differs=1 fi ___diff_immutable_data \ "$DIFF_PGDATA1/sock" "5432" \ "$DIFF_PGDATA2/sock" "5432" \ "$DIFF_TMPDIR/diff" data_status=$? if [ "$schema_differs" = 0 ] && [ "$data_status" = 0 ]; then log notice "no differences found" else "$PAGER_OR_CAT" "$DIFF_TMPDIR/diff" fi restore_namespace } # ALLOWED_ACTIONS='backup,log' if ! [ "${AS_LIBRARY+x}" ]; then if [ $# -eq 0 ]; then help exit 1 fi if [ "$1" = '-h' ] || [ "$1" = '--help' ] || [ "$1" = '-?' ] || [ "$1" = 'help' ] || [ "$1" = '?' ]; then help exit 0 fi while [ $# -gt 0 ]; do case $1 in -u|--url) PGURL=$2 DB_URL=$2 shift 2 ;; normalize-backup) if [ "${SUBCOMMAND+x}" ]; then REMAINING_ARGS="$REMAINING_ARGS $(quote "$1")" else SUBCOMMAND="normalize_backup" fi shift ;; deploy|replay|restore|patch|hydrate|backup|log|migrator|diff|pull_staging|test|check|cleanup|init) if [ "${SUBCOMMAND+x}" ]; then REMAINING_ARGS="$REMAINING_ARGS $(quote "$1")" else SUBCOMMAND="$1" fi shift ;; -i|--inherits) INHERITS_LIST="${INHERITS_LIST+$INHERITS_LIST\"}$2" shift 2 ;; *) REMAINING_ARGS="$REMAINING_ARGS $(quote "$1")"; shift ;; esac done [ "${SUBCOMMAND+x}" ] || ( log error "no subcommand specified. Use '--help' for usage information."; exit 1; ) # SAFETY: do not allow use on remote database #if ! printf '%s\n' "$ALLOWED_ACTIONS" | grep "$SUBCOMMAND"; then # IS_REMOTE="$(psql "$PGURL" -tAc "SELECT (inet_client_addr() IS NOT NULL)::int;")" # [ "$IS_REMOTE" = 1 ] && (log error "THIS IS NOT LOCAL DATABSE, BASTARD"; exit 1) #fi [ "${INHERITS_LIST+x}" ] && { INHERITS_LIST="$(printf '%s' "$INHERITS_LIST" | sed -E 's/"/,/g; s/([^,]+)/"\1"/g')" old_IFS="$IFS" IFS=',' check_inherits= for table in $INHERITS_LIST; do check_inherits="$(printf '%s\nSELECT 1 FROM %s LIMIT 1;' "$check_inherits" "$table")" done IFS="$old_IFS" check_inherits=$(printf '%s\n' \ 'BEGIN;' \ "$check_inherits" \ 'COMMIT;') if ! db_exec "$check_inherits"; then log error "init failed: ${WHITE}one of inherits table does not exists: ${CYAN}$INHERITS_LIST" exit 5 fi } parsed_uri=$(mktemp) trap 'rm -rf "$parsed_uri"' EXIT INT HUP parse-uri "${PGURL:-}" > "$parsed_uri" # shellcheck disable=SC1090 . "$parsed_uri" [ "$REMAINING_ARGS" = '' ] || eval "set -- $REMAINING_ARGS"; REMAINING_ARGS= "subcommand_$SUBCOMMAND" "$@" fi