north-star / files / scripts / clean-old-firebase.sh
clean-old-firebase.sh
| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | FIREBASE_BIN="./node_modules/.bin/firebase" |
| 5 | PROJECT="${FIREBASE_PROJECT:-north-star-e5dee}" |
| 6 | INSTANCE="${FIREBASE_RTDB_INSTANCE:-north-star-e5dee-default-rtdb}" |
| 7 | CONFIG="config/firebase/firebase.json" |
| 8 | RETENTION_MS=$(( 30 * 24 * 60 * 60 * 1000 )) |
| 9 | RETENTION_LABEL="30 days" |
| 10 | |
| 11 | APPLY=false |
| 12 | VERBOSE=false |
| 13 | for arg in "$@"; do |
| 14 | case "$arg" in |
| 15 | --apply) APPLY=true ;; |
| 16 | --verbose) VERBOSE=true ;; |
| 17 | --dry-run) : ;; # default is preview; --dry-run is a no-op |
| 18 | --days=*) N="${arg#--days=}" |
| 19 | RETENTION_MS=$(( N * 24 * 60 * 60 * 1000 )) |
| 20 | RETENTION_LABEL="${N} day(s)" ;; |
| 21 | --hours=*) N="${arg#--hours=}" |
| 22 | RETENTION_MS=$(( N * 60 * 60 * 1000 )) |
| 23 | RETENTION_LABEL="${N} hour(s)" ;; |
| 24 | --minutes=*) N="${arg#--minutes=}" |
| 25 | RETENTION_MS=$(( N * 60 * 1000 )) |
| 26 | RETENTION_LABEL="${N} minute(s)" ;; |
| 27 | esac |
| 28 | done |
| 29 | |
| 30 | if ! command -v jq &> /dev/null; then |
| 31 | echo "ERROR: jq is required. Install with: brew install jq" >&2 |
| 32 | exit 1 |
| 33 | fi |
| 34 | |
| 35 | run_firebase() { "$FIREBASE_BIN" --config "$CONFIG" --project "$PROJECT" "$@"; } |
| 36 | |
| 37 | sanitize_firebase_json_output() { |
| 38 | local raw="$1" |
| 39 | |
| 40 | # Drop ANSI color codes/noise and trim blank lines. |
| 41 | local clean |
| 42 | clean=$(printf '%s\n' "$raw" | sed -E 's/\x1B\[[0-9;]*[A-Za-z]//g' | sed '/^[[:space:]]*$/d') |
| 43 | |
| 44 | # firebase-tools can print warnings before payload and/or append |
| 45 | # a trailing status object. Select only the first real payload line. |
| 46 | # Expected payload is compact JSON object or literal null. |
| 47 | clean=$(printf '%s\n' "$clean" | awk ' |
| 48 | /^[[:space:]]*null[[:space:]]*$/ { print "null"; exit } |
| 49 | /^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:/ { next } |
| 50 | /^[[:space:]]*\{/ { print; exit } |
| 51 | ') |
| 52 | |
| 53 | printf '%s' "$clean" |
| 54 | } |
| 55 | |
| 56 | ROOMS_RAW=$(run_firebase database:get /rooms --instance "$INSTANCE") || { |
| 57 | echo "ERROR: Failed to reach Firebase. Is the CLI authenticated?" >&2 |
| 58 | echo " Run: npm exec firebase -- login" >&2 |
| 59 | exit 1 |
| 60 | } |
| 61 | |
| 62 | ROOMS_JSON=$(sanitize_firebase_json_output "$ROOMS_RAW") |
| 63 | |
| 64 | if ! printf '%s' "$ROOMS_JSON" | jq -e . >/dev/null 2>&1; then |
| 65 | echo "ERROR: Firebase returned a non-JSON /rooms payload in CI." >&2 |
| 66 | echo "Raw output (first 400 chars):" >&2 |
| 67 | printf '%s' "$ROOMS_RAW" | head -c 400 >&2 |
| 68 | echo >&2 |
| 69 | exit 1 |
| 70 | fi |
| 71 | |
| 72 | if [[ "$ROOMS_JSON" == "null" ]] || [[ -z "$ROOMS_JSON" ]]; then |
| 73 | echo "No rooms data found at /rooms in instance $INSTANCE." |
| 74 | exit 0 |
| 75 | fi |
| 76 | |
| 77 | # node is already a dev dependency; use it for millisecond-accurate time (date +%s lacks ms on macOS) |
| 78 | NOW_MS=$(node -e "process.stdout.write(String(Date.now()))") |
| 79 | |
| 80 | STALE_PATHS=$(echo "$ROOMS_JSON" | jq -r \ |
| 81 | --argjson now "$NOW_MS" --argjson r "$RETENTION_MS" ' |
| 82 | to_entries[] | |
| 83 | .key as $room | |
| 84 | (.value.friends // {}) | |
| 85 | to_entries[] | |
| 86 | select(.value.lastSeen != null) | |
| 87 | select((.value.lastSeen | tonumber) + $r < $now) | |
| 88 | "/rooms/\($room)/friends/\(.key)"') |
| 89 | |
| 90 | STALE_ROOM_PATHS=$(echo "$ROOMS_JSON" | jq -r \ |
| 91 | --argjson now "$NOW_MS" --argjson r "$RETENTION_MS" ' |
| 92 | [to_entries[] | |
| 93 | .key as $room | |
| 94 | (.value.friends // {}) | |
| 95 | to_entries[] | |
| 96 | select(.value.lastSeen != null) | |
| 97 | select((.value.lastSeen | tonumber) + $r < $now) | |
| 98 | $room] | unique[] | "/rooms/\(.)"') |
| 99 | |
| 100 | STATS=$(echo "$ROOMS_JSON" | jq -r ' |
| 101 | "rooms=\(to_entries|length), roomsWithFriends=\([to_entries[]|select(.value.friends)]|length), friendsScanned=\([to_entries[]|(.value.friends//{})|to_entries[]]|length), friendsWithLastSeen=\([to_entries[]|(.value.friends//{})|to_entries[]|select(.value.lastSeen)]|length)"') |
| 102 | |
| 103 | if [[ -z "$STALE_PATHS" ]]; then |
| 104 | echo "No stale friends older than ${RETENTION_LABEL} found. ($STATS)" |
| 105 | exit 0 |
| 106 | fi |
| 107 | |
| 108 | STALE_COUNT=$(echo "$STALE_PATHS" | wc -l | tr -d ' ') |
| 109 | TOTAL_ROOMS=$(echo "$ROOMS_JSON" | jq 'to_entries | length') |
| 110 | STALE_ROOMS=$(echo "$ROOMS_JSON" | jq \ |
| 111 | --argjson now "$NOW_MS" --argjson r "$RETENTION_MS" ' |
| 112 | [to_entries[] | |
| 113 | select(any(.value.friends // {} | to_entries[]; |
| 114 | .value.lastSeen != null and (.value.lastSeen|tonumber) + $r < $now))] | length') |
| 115 | echo "Found $STALE_COUNT stale friend record(s) older than ${RETENTION_LABEL} across $STALE_ROOMS/$TOTAL_ROOMS room(s) (project=$PROJECT, instance=$INSTANCE)." |
| 116 | |
| 117 | if [[ "$APPLY" == "false" ]]; then |
| 118 | echo "Preview mode: no deletions performed." |
| 119 | if [[ "$VERBOSE" == "true" ]]; then |
| 120 | echo "$ROOMS_JSON" | jq -r \ |
| 121 | --argjson now "$NOW_MS" --argjson r "$RETENTION_MS" ' |
| 122 | [to_entries[] | |
| 123 | .key as $room | |
| 124 | (.value.friends // {}) | |
| 125 | to_entries[] | |
| 126 | select(.value.lastSeen != null) | |
| 127 | select((.value.lastSeen | tonumber) + $r < $now) | |
| 128 | {room: $room, lastSeen: (.value.lastSeen|tonumber)}] | |
| 129 | group_by(.room)[] | |
| 130 | (.[0].room) as $room | |
| 131 | (map(.lastSeen) | max) as $newest | |
| 132 | ($now - $newest) as $age | |
| 133 | (($age/86400000|floor) as $d | (($age%86400000)/3600000|floor) as $h | |
| 134 | (($age%3600000)/60000|floor) as $m | |
| 135 | if $d > 0 then "\($d)d \($h)h" |
| 136 | elif $h > 0 then "\($h)h \($m)m" |
| 137 | else "\($m)m" end) as $oldestActive | |
| 138 | "\($room) (\(length) stale friend(s), most recent active \($oldestActive) ago)"' |
| 139 | else |
| 140 | echo "$STALE_ROOM_PATHS" | sed 's#^/rooms/##' |
| 141 | fi |
| 142 | echo "Re-run with --apply to delete matching rooms." |
| 143 | exit 0 |
| 144 | fi |
| 145 | |
| 146 | DELETED=0 |
| 147 | FAILED=0 |
| 148 | ROOM_DELETE_COUNT=$(echo "$STALE_ROOM_PATHS" | wc -l | tr -d ' ') |
| 149 | CHUNK_SIZE=50 |
| 150 | mapfile -t STALE_ROOM_IDS < <(echo "$STALE_ROOM_PATHS" | sed 's#^/rooms/##') |
| 151 | |
| 152 | for ((i = 0; i < ${#STALE_ROOM_IDS[@]}; i += CHUNK_SIZE)); do |
| 153 | chunk=("${STALE_ROOM_IDS[@]:i:CHUNK_SIZE}") |
| 154 | payload=$(printf '%s\n' "${chunk[@]}" | jq -R . | jq -s 'map(select(length > 0)) | reduce .[] as $k ({}; . + {($k): null})') |
| 155 | |
| 156 | if run_firebase database:update /rooms --instance "$INSTANCE" --data "$payload" --force 2>/dev/null; then |
| 157 | DELETED=$(( DELETED + ${#chunk[@]} )) |
| 158 | if [[ "$VERBOSE" == "true" ]]; then |
| 159 | echo "Deleted rooms batch: ${#chunk[@]} room(s)" |
| 160 | fi |
| 161 | else |
| 162 | FAILED=$(( FAILED + ${#chunk[@]} )) |
| 163 | echo "Failed to delete rooms batch starting at index $i (size=${#chunk[@]})." >&2 |
| 164 | fi |
| 165 | done |
| 166 | |
| 167 | echo "Deleted $DELETED/$ROOM_DELETE_COUNT room(s) containing $STALE_COUNT stale friend record(s)." |
| 168 | [[ "$FAILED" -gt 0 ]] && echo "$FAILED room deletion(s) failed." >&2 |