Skip to content

Instantly share code, notes, and snippets.

@blakedietz
Created March 25, 2025 15:34
Show Gist options
  • Save blakedietz/db3164e77f5fcf4768c38901dfb386ad to your computer and use it in GitHub Desktop.
Save blakedietz/db3164e77f5fcf4768c38901dfb386ad to your computer and use it in GitHub Desktop.
#!/usr/bin/env fish
# PostgreSQL Database Snapshot Management Script
# Commands:
# - snapshot [name] [--notes "description"]: Create database snapshots for all DBs with a matching prefix and archive them (uses date/time if no name provided)
# - restore: Use fzf to select an archive and restore snapshots from it
# - delete: Use fzf to select an archive to delete
# Configuration
set -g BACKUP_DIR /Users/blakedietz/projects/Jump-App/database-backups
set -g ARCHIVE_DIR /Users/blakedietz/projects/Jump-App/database-backups/archives
set -g TEMP_DIR /tmp/db-restore
set -g METADATA_DB /Users/blakedietz/projects/Jump-App/database-backups/snapshots.db
set -g DB_PREFIX jump_ # Prefix for databases to backup/restore
set -g DB_USER postgres # Set your database user here
set -g DB_HOST localhost # Set your database host here
set -g DB_PORT 5432 # Set your database port here
set -g DB_PASSWORD postgres # Development password (not sensitive)
# Path to PostgreSQL binaries - adjust based on your installation
# For Postgres.app, try to use its binaries to avoid version mismatch
set -g PG_BIN_PATH "/Applications/Postgres.app/Contents/Versions/latest/bin"
function init_metadata_db
# Create metadata database if it doesn't exist
if not test -f $METADATA_DB
sqlite3 $METADATA_DB "
CREATE TABLE IF NOT EXISTS snapshots (
name TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
notes TEXT,
databases TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);"
end
end
function ensure_dirs
for dir in $BACKUP_DIR $ARCHIVE_DIR $TEMP_DIR
if not test -d $dir
mkdir -p $dir
echo "Created directory: $dir"
end
end
init_metadata_db
end
function get_matching_databases
set -l databases (PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -t -c "SELECT datname FROM pg_database WHERE datname LIKE '$DB_PREFIX%';")
# Clean up the output (remove whitespace) and filter out empty strings
for db in $databases
set -l trimmed (string trim $db)
if test -n "$trimmed"
echo $trimmed
end
end
end
function db_snapshot
ensure_dirs
set timestamp (date +"%Y%m%d_%H%M%S")
set -l notes_opt (fish_opt --short=n --long=notes --required-val)
argparse $notes_opt -- $argv
or return 1
if test (count $argv) -gt 0
set archive_name "$argv[1]"
else
set archive_name "snapshot_$timestamp"
end
# If notes weren't provided via flag, prompt for them
if not set -q _flag_notes
read -P "Enter notes for this snapshot (optional): " _flag_notes
end
# Get all databases matching the prefix
set matching_dbs (get_matching_databases)
if test (count $matching_dbs) -eq 0
echo "❌ No databases found with prefix '$DB_PREFIX'"
return 1
end
echo "Found "(count $matching_dbs)" databases matching prefix '$DB_PREFIX'"
# Create a temporary directory for snapshots
set temp_snapshot_dir "$TEMP_DIR/$archive_name"
mkdir -p $temp_snapshot_dir
# Save metadata to SQLite
if test -n "$_flag_notes"
sqlite3 $METADATA_DB "INSERT INTO snapshots (name, timestamp, notes, databases) VALUES ('$archive_name', '$timestamp', '$_flag_notes', '$(string join ',' $matching_dbs)');"
else
sqlite3 $METADATA_DB "INSERT INTO snapshots (name, timestamp, databases) VALUES ('$archive_name', '$timestamp', '$(string join ',' $matching_dbs)');"
end
for db in $matching_dbs
set backup_path "$temp_snapshot_dir/$db.sql"
echo "Creating snapshot of database '$db': $backup_path"
# Use Postgres.app pg_dump instead of Homebrew version
set pg_dump_path "$PG_BIN_PATH/pg_dump"
# Check if Postgres.app pg_dump exists, otherwise try to use the system one
if not test -e $pg_dump_path
echo "Warning: Could not find Postgres.app pg_dump at $pg_dump_path"
echo "Trying system pg_dump, but version mismatch is likely..."
set pg_dump_path pg_dump
end
PGPASSWORD=$DB_PASSWORD $pg_dump_path -h $DB_HOST -p $DB_PORT -U $DB_USER -d $db -F p >$backup_path
if test $status -eq 0
echo "✅ Snapshot of '$db' created successfully!"
else
echo "❌ Failed to create snapshot of '$db'!"
rm -rf $temp_snapshot_dir
sqlite3 $METADATA_DB "DELETE FROM snapshots WHERE name = '$archive_name';"
return 1
end
end
# Create archive
set archive_path "$ARCHIVE_DIR/$archive_name.tar.gz"
tar -czf $archive_path -C $temp_snapshot_dir .
if test $status -eq 0
echo "✅ Created archive: $archive_path"
test -n "$_flag_notes" && echo "📝 Notes: $_flag_notes"
# Clean up temporary files
rm -rf $temp_snapshot_dir
else
echo "❌ Failed to create archive!"
rm -rf $temp_snapshot_dir
sqlite3 $METADATA_DB "DELETE FROM snapshots WHERE name = '$archive_name';"
return 1
end
end
function db_restore
ensure_dirs
# Check if fzf is installed
if not command -q fzf
echo "❌ fzf is not installed. Please install it with 'brew install fzf' or your package manager."
return 1
end
# Check if there are any archives
set archive_count (count $ARCHIVE_DIR/*.tar.gz)
if test $archive_count -eq 0
echo "❌ No archives found in $ARCHIVE_DIR"
return 1
end
# Create a temporary preview script
set preview_script (mktemp)
echo '#!/usr/bin/env fish
set archive_path $argv[1]
set archive_name (basename $archive_path .tar.gz)
echo "Archive contents:"
echo "----------------"
tar -tf $archive_path | sed "s/.*\///"
echo -e "\nMetadata:"
echo "----------------"
sqlite3 "'$METADATA_DB'" "
SELECT
\"Created: \" || datetime(created_at, \"localtime\") || \"\n\" ||
\"Timestamp: \" || timestamp || \"\n\" ||
\"Databases: \" || databases ||
CASE WHEN notes IS NOT NULL THEN \"\nNotes: \" || notes ELSE \"\" END
FROM snapshots WHERE name = \"$archive_name\";"
' > $preview_script
chmod +x $preview_script
# Use fzf to select an archive, showing both contents and metadata in preview
set selected_archive (find $ARCHIVE_DIR -name "*.tar.gz" -type f | \
sort -r | \
awk -F/ '{print $NF "\t" $0}' | \
fzf --with-nth=1 \
--preview "eval $preview_script {2}" \
--preview-window="right:50%" \
--height 40% \
--layout reverse \
--border | \
cut -f2)
# Clean up the preview script
rm $preview_script
if test -z "$selected_archive"
echo "No archive selected, operation cancelled."
return 0
end
# Show metadata before proceeding
set -l archive_name (basename $selected_archive .tar.gz)
echo -e "\nSnapshot Information:"
echo "--------------------"
sqlite3 $METADATA_DB "
SELECT
\"Created: \" || datetime(created_at, \"localtime\") || \"\n\" ||
\"Timestamp: \" || timestamp || \"\n\" ||
\"Databases: \" || databases ||
CASE WHEN notes IS NOT NULL THEN \"\nNotes: \" || notes ELSE \"\" END
FROM snapshots WHERE name = \"$archive_name\";"
echo "--------------------"
# Create a clean temporary directory
rm -rf $TEMP_DIR
mkdir -p $TEMP_DIR
echo "Selected archive: $selected_archive"
echo "Extracting archive..."
# Extract the archive
tar -xzf $selected_archive -C $TEMP_DIR
# Get all SQL files from the extracted archive
set snapshot_files $TEMP_DIR/*.sql
# Ask if user wants to restore all databases at once
read -l -P "Do you want to restore all databases without individual prompts? [y/N] " restore_all
for snapshot_file in $snapshot_files
# The filename is the database name
set db_name (string replace -r '.*/([^/]+)\.sql$' '$1' $snapshot_file)
if test -z "$db_name"
echo "❌ Could not determine database name from filename: $snapshot_file"
continue
end
echo "Found snapshot for database '$db_name': $snapshot_file"
set proceed false
# If restore_all is yes, proceed without individual prompts
switch $restore_all
case Y y
set proceed true
case '*'
read -l -P "Are you sure you want to restore '$db_name'? This will overwrite your current database. [y/N] " confirm
switch $confirm
case Y y
set proceed true
end
end
if test $proceed = true
echo "Restoring database '$db_name' from: $snapshot_file"
# Get path to psql executable
set psql_path "$PG_BIN_PATH/psql"
if not test -e $psql_path
echo "Warning: Could not find Postgres.app psql at $psql_path"
echo "Trying system psql..."
set psql_path psql
end
# Drop and recreate the database
PGPASSWORD=$DB_PASSWORD $psql_path -h $DB_HOST -p $DB_PORT -U $DB_USER -c "DROP DATABASE IF EXISTS $db_name WITH (FORCE);"
PGPASSWORD=$DB_PASSWORD $psql_path -h $DB_HOST -p $DB_PORT -U $DB_USER -c "CREATE DATABASE $db_name;"
# Restore from snapshot
PGPASSWORD=$DB_PASSWORD $psql_path -h $DB_HOST -p $DB_PORT -U $DB_USER -d $db_name <$snapshot_file
if test $status -eq 0
echo "✅ Database '$db_name' restored successfully!"
else
echo "❌ Failed to restore database '$db_name'!"
end
else
echo "Restore of '$db_name' cancelled."
end
end
# Clean up temporary files
rm -rf $TEMP_DIR
echo "✨ Cleanup complete"
end
function db_delete
ensure_dirs
# Check if fzf is installed
if not command -q fzf
echo "❌ fzf is not installed. Please install it with 'brew install fzf' or your package manager."
return 1
end
# Check if there are any archives
set archive_count (count $ARCHIVE_DIR/*.tar.gz)
if test $archive_count -eq 0
echo "❌ No archives found in $ARCHIVE_DIR"
return 1
end
# Create a temporary preview script
set preview_script (mktemp)
echo '#!/usr/bin/env fish
set archive_path $argv[1]
set archive_name (basename $archive_path .tar.gz)
echo "Archive contents:"
echo "----------------"
tar -tf $archive_path | sed "s/.*\///"
echo -e "\nMetadata:"
echo "----------------"
sqlite3 "'$METADATA_DB'" "
SELECT
\"Created: \" || datetime(created_at, \"localtime\") || \"\n\" ||
\"Timestamp: \" || timestamp || \"\n\" ||
\"Databases: \" || databases ||
CASE WHEN notes IS NOT NULL THEN \"\nNotes: \" || notes ELSE \"\" END
FROM snapshots WHERE name = \"$archive_name\";"
' > $preview_script
chmod +x $preview_script
# Use fzf to select an archive to delete
set selected_archive (find $ARCHIVE_DIR -name "*.tar.gz" -type f | \
sort -r | \
awk -F/ '{print $NF "\t" $0}' | \
fzf --with-nth=1 \
--preview "eval $preview_script {2}" \
--preview-window="right:50%" \
--height 40% \
--layout reverse \
--border | \
cut -f2)
# Clean up the preview script
rm $preview_script
if test -z "$selected_archive"
echo "No archive selected, operation cancelled."
return 0
end
# Show metadata before proceeding
set -l archive_name (basename $selected_archive .tar.gz)
echo -e "\nSnapshot Information to be deleted:"
echo "--------------------"
sqlite3 $METADATA_DB "
SELECT
\"Created: \" || datetime(created_at, \"localtime\") || \"\n\" ||
\"Timestamp: \" || timestamp || \"\n\" ||
\"Databases: \" || databases ||
CASE WHEN notes IS NOT NULL THEN \"\nNotes: \" || notes ELSE \"\" END
FROM snapshots WHERE name = \"$archive_name\";"
echo "--------------------"
read -l -P "Are you sure you want to delete this snapshot? This action cannot be undone. [y/N] " confirm
switch $confirm
case Y y
# Delete the archive file
rm $selected_archive
if test $status -eq 0
# Remove the metadata from SQLite
sqlite3 $METADATA_DB "DELETE FROM snapshots WHERE name = '$archive_name';"
echo "✅ Snapshot '$archive_name' deleted successfully!"
else
echo "❌ Failed to delete archive file!"
return 1
end
case '*'
echo "Deletion cancelled."
return 0
end
end
# Parse command line arguments
if test (count $argv) -eq 0
echo "Usage: "(status current-filename)" [snapshot|restore|delete] [name] [--notes \"description\"]"
exit 1
end
switch $argv[1]
case snapshot
db_snapshot $argv[2..-1]
case restore
db_restore
case delete
db_delete
case '*'
echo "Unknown command: $argv[1]"
echo "Available commands: snapshot, restore, delete"
exit 1
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment