fuzzyworld

north-star / files / scripts / clean-old-firebase.sh

clean-old-firebase.sh

6.4 KB · history · blame

1#!/usr/bin/env bash
2set -euo pipefail
3
4FIREBASE_BIN="./node_modules/.bin/firebase"
5PROJECT="${FIREBASE_PROJECT:-north-star-e5dee}"
6INSTANCE="${FIREBASE_RTDB_INSTANCE:-north-star-e5dee-default-rtdb}"
7CONFIG="config/firebase/firebase.json"
8RETENTION_MS=$(( 30 * 24 * 60 * 60 * 1000 ))
9RETENTION_LABEL="30 days"
10
11APPLY=false
12VERBOSE=false
13for 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
28done
29
30if ! command -v jq &> /dev/null; then
31 echo "ERROR: jq is required. Install with: brew install jq" >&2
32 exit 1
33fi
34
35run_firebase() { "$FIREBASE_BIN" --config "$CONFIG" --project "$PROJECT" "$@"; }
36
37sanitize_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
56ROOMS_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
62ROOMS_JSON=$(sanitize_firebase_json_output "$ROOMS_RAW")
63
64if ! 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
70fi
71
72if [[ "$ROOMS_JSON" == "null" ]] || [[ -z "$ROOMS_JSON" ]]; then
73 echo "No rooms data found at /rooms in instance $INSTANCE."
74 exit 0
75fi
76
77# node is already a dev dependency; use it for millisecond-accurate time (date +%s lacks ms on macOS)
78NOW_MS=$(node -e "process.stdout.write(String(Date.now()))")
79
80STALE_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
90STALE_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
100STATS=$(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
103if [[ -z "$STALE_PATHS" ]]; then
104 echo "No stale friends older than ${RETENTION_LABEL} found. ($STATS)"
105 exit 0
106fi
107
108STALE_COUNT=$(echo "$STALE_PATHS" | wc -l | tr -d ' ')
109TOTAL_ROOMS=$(echo "$ROOMS_JSON" | jq 'to_entries | length')
110STALE_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')
115echo "Found $STALE_COUNT stale friend record(s) older than ${RETENTION_LABEL} across $STALE_ROOMS/$TOTAL_ROOMS room(s) (project=$PROJECT, instance=$INSTANCE)."
116
117if [[ "$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
144fi
145
146DELETED=0
147FAILED=0
148ROOM_DELETE_COUNT=$(echo "$STALE_ROOM_PATHS" | wc -l | tr -d ' ')
149CHUNK_SIZE=50
150mapfile -t STALE_ROOM_IDS < <(echo "$STALE_ROOM_PATHS" | sed 's#^/rooms/##')
151
152for ((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
165done
166
167echo "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