1/** 2 * Opportunistic client-side cleanup of stale Firebase RTDB room / invite nodes. 3 * Throttled per browser (localStorage). Does not touch scores, wallets, meta, or dnd/groups. 4 * 5 * Lists with REST ?shallow=true so huge room bodies (strokes / sim state) are not downloaded. 6 * 7 * window.StaleRoomSweeper.runIfDue() â dashboard hook 8 * window.StaleRoomSweeper.prunePishockLogs() â admin-only (needs log read access) 9 */ 10(function (global) { 11 'use strict'; 12 13 var THROTTLE_KEY = 'pupwife_stale_room_sweep_at'; 14 var THROTTLE_MS = 30 * 60 * 1000; 15 var MAX_DELETES_PER_RUN = 100; 16 17 var HOUR = 60 * 60 * 1000; 18 var DAY = 24 * HOUR; 19 20 var FINISHED_MIN_AGE_MS = 1 * HOUR; 21 var ABANDONED_AGE_MS = 6 * HOUR; 22 var PLAYING_GRACE_MS = 6 * HOUR; 23 var DRAW_EMPTY_MS = 1 * DAY; 24 var DRAW_HARD_CAP_MS = 7 * DAY; 25 var GOLF_IDLE_MS = 2 * HOUR; 26 var PET_STALE_MS = 10 * 60 * 1000; 27 var INVITE_SETTLED_MS = 1 * HOUR; 28 var PISHOCK_LOG_MAX_AGE_MS = 7 * DAY; 29 30 var running = false; 31 32 function emitLog() { 33 if (typeof global.devLog === 'function') { 34 global.devLog.apply(global, arguments); 35 return; 36 } 37 if (typeof console !== 'undefined' && console.log) { 38 console.log.apply(console, arguments); 39 } 40 } 41 42 function databaseUrl() { 43 try { 44 if (global.firebase && global.firebase.app) { 45 var opts = global.firebase.app().options; 46 if (opts && opts.databaseURL) return String(opts.databaseURL).replace(/\/$/, ''); 47 } 48 } catch (e) {} 49 return 'https://pongbreaker-c7868-default-rtdb.firebaseio.com'; 50 } 51 52 function db() { 53 if (typeof global.firebase === 'undefined' || !global.firebase.database) return null; 54 return global.firebase.database(); 55 } 56 57 function encodePath(rtdbPath) { 58 return String(rtdbPath || '') 59 .split('/') 60 .filter(Boolean) 61 .map(function (s) { 62 return encodeURIComponent(s); 63 }) 64 .join('/'); 65 } 66 67 function restUrl(rtdbPath, query) { 68 return databaseUrl() + '/' + encodePath(rtdbPath) + '.json' + (query || ''); 69 } 70 71 function fetchJson(rtdbPath, query) { 72 return fetch(restUrl(rtdbPath, query || '')).then(function (res) { 73 if (!res.ok) { 74 return res.text().then(function (body) { 75 throw new Error('GET ' + rtdbPath + ' HTTP ' + res.status + ' ' + String(body || '').slice(0, 120)); 76 }); 77 } 78 return res.json(); 79 }); 80 } 81 82 function shallowKeys(rtdbPath) { 83 return fetchJson(rtdbPath, '?shallow=true').then(function (data) { 84 if (!data || typeof data !== 'object') return []; 85 return Object.keys(data); 86 }).catch(function () { 87 return []; 88 }); 89 } 90 91 function readField(rtdbPath) { 92 return fetchJson(rtdbPath).catch(function () { 93 return null; 94 }); 95 } 96 97 function removePath(rtdbPath) { 98 var database = db(); 99 if (!database) { 100 return fetch(restUrl(rtdbPath), { method: 'DELETE' }).then(function () {}).catch(function () {}); 101 } 102 return database.ref(rtdbPath).remove().catch(function () {}); 103 } 104 105 function readThrottleAt() { 106 try { 107 return Number(global.localStorage.getItem(THROTTLE_KEY) || 0) || 0; 108 } catch (e) { 109 return 0; 110 } 111 } 112 113 function writeThrottleAt(now) { 114 try { 115 global.localStorage.setItem(THROTTLE_KEY, String(now)); 116 } catch (e) {} 117 } 118 119 function ageOf(ts, now) { 120 var n = Number(ts || 0); 121 if (!n) return null; 122 return now - n; 123 } 124 125 function isFinishedStatus(status) { 126 return status === 'finished' || status === 'expired'; 127 } 128 129 function isPlayingStatus(status) { 130 return status === 'playing' || status === 'ready'; 131 } 132 133 function shouldDeleteByStatusAge(status, created, now) { 134 var age = ageOf(created, now); 135 if (isPlayingStatus(status)) { 136 if (age == null) return false; 137 return age > PLAYING_GRACE_MS; 138 } 139 if (isFinishedStatus(status)) { 140 if (age == null) return true; 141 return age > FINISHED_MIN_AGE_MS; 142 } 143 if (age == null) return false; 144 return age > ABANDONED_AGE_MS; 145 } 146 147 function shouldDeleteInvite(node, now) { 148 if (!node || typeof node !== 'object') return false; 149 var expiresAt = Number(node.expiresAt || 0); 150 if (expiresAt && expiresAt < now) return true; 151 var status = String(node.status || ''); 152 if (status === 'expired' || status === 'accepted' || status === 'declined') { 153 var created = Number(node.createdAt || 0); 154 if (!created) return true; 155 return now - created > INVITE_SETTLED_MS; 156 } 157 return false;
158 } 159 160 function takeBudget(budget) { 161 if (budget.left <= 0) return false; 162 budget.left -= 1; 163 return true; 164 } 165 166 function sweepClassicRooms(path, now, budget) { 167 return shallowKeys(path).then(function (keys) { 168 var deleted = 0; 169 var chain = Promise.resolve(); 170 keys.forEach(function (key) { 171 chain = chain.then(function () { 172 if (budget.left <= 0) return; 173 var base = path + '/' + key; 174 return Promise.all([ 175 readField(base + '/status'), 176 readField(base + '/createdAt') 177 ]).then(function (pair) { 178 var status = typeof pair[0] === 'string' ? pair[0] : ''; 179 var created = Number(pair[1] || 0); 180 if (!shouldDeleteByStatusAge(status, created, now)) return; 181 if (!takeBudget(budget)) return; 182 deleted += 1; 183 return removePath(base); 184 }); 185 }); 186 }); 187 return chain.then(function () { 188 return deleted; 189 }); 190 }); 191 } 192 193 function sweepCasinoRooms(path, now, budget) { 194 return shallowKeys(path).then(function (keys) { 195 var deleted = 0; 196 var chain = Promise.resolve(); 197 keys.forEach(function (key) { 198 chain = chain.then(function () { 199 if (budget.left <= 0) return; 200 var base = path + '/' + key; 201 return Promise.all([ 202 readField(base + '/meta/status'), 203 readField(base + '/meta/createdAt') 204 ]).then(function (pair) { 205 var status = typeof pair[0] === 'string' ? pair[0] : ''; 206 var created = Number(pair[1] || 0); 207 if (!shouldDeleteByStatusAge(status, created, now)) return; 208 if (!takeBudget(budget)) return; 209 deleted += 1; 210 return removePath(base); 211 }); 212 }); 213 }); 214 return chain.then(function () { 215 return deleted; 216 }); 217 }); 218 } 219 220 function sweepCasinoInvites(now, budget) { 221 return shallowKeys('casinoInvites').then(function (users) { 222 var deleted = 0; 223 var chain = Promise.resolve(); 224 users.forEach(function (user) { 225 chain = chain.then(function () { 226 if (budget.left <= 0) return; 227 return shallowKeys('casinoInvites/' + user).then(function (inviteIds) { 228 var inner = Promise.resolve(); 229 inviteIds.forEach(function (inviteId) { 230 inner = inner.then(function () { 231 if (budget.left <= 0) return; 232 var leaf = 'casinoInvites/' + user + '/' + inviteId; 233 return readField(leaf).then(function (node) { 234 if (!shouldDeleteInvite(node, now)) return; 235 if (!takeBudget(budget)) return; 236 deleted += 1; 237 return removePath(leaf); 238 }); 239 }); 240 }); 241 return inner; 242 }); 243 }); 244 }); 245 return chain.then(function () { 246 return deleted; 247 }); 248 }); 249 } 250 251 function sweepTriviaRooms(now, budget) { 252 return shallowKeys('trivia/rooms').then(function (keys) { 253 var deleted = 0; 254 var chain = Promise.resolve(); 255 keys.forEach(function (key) { 256 chain = chain.then(function () { 257 if (budget.left <= 0) return; 258 var base = 'trivia/rooms/' + key; 259 return Promise.all([ 260 readField(base + '/meta/status'), 261 readField(base + '/meta/createdAt'), 262 readField(base + '/state/finishedAt') 263 ]).then(function (triple) { 264 var status = typeof triple[0] === 'string' ? triple[0] : ''; 265 var created = Number(triple[1] || 0); 266 var finishedAt = Number(triple[2] || 0); 267 var drop = false; 268 if (finishedAt || isFinishedStatus(status)) { 269 var finAge = finishedAt ? now - finishedAt : ageOf(created, now); 270 drop = finAge == null ? true : finAge > FINISHED_MIN_AGE_MS; 271 } else if (isPlayingStatus(status) || status === 'active' || status === 'question') { 272 var playAge = ageOf(created, now); 273 drop = playAge != null && playAge > PLAYING_GRACE_MS; 274 } else { 275 var age = ageOf(created, now); 276 drop = age != null && age > ABANDONED_AGE_MS; 277 } 278 if (!drop) return; 279 if (!takeBudget(budget)) return; 280 deleted += 1; 281 return removePath(base); 282 }); 283 }); 284 }); 285 return chain.then(function () { 286 return deleted; 287 }); 288 }); 289 } 290 291 function sweepDrawTogether(now, budget) { 292 return shallowKeys('drawTogether/rooms').then(function (keys) { 293 var deleted = 0; 294 var chain = Promise.resolve(); 295 keys.forEach(function (key) { 296 chain = chain.then(function () { 297 if (budget.left <= 0) return; 298 var base = 'drawTogether/rooms/' + key; 299 return Promise.all([ 300 readField(base + '/createdAt'), 301 readField(base + '/roomCode'), 302 fetchJson(base + '/participants', '?shallow=true').catch(function () { 303 return null; 304 }) 305 ]).then(function (triple) { 306 var created = Number(triple[0] || 0); 307 var roomCode = triple[1]; 308 var participants = triple[2]; 309 var age = ageOf(created, now); 310 if (age == null) return; 311 var empty = 312 !participants || 313 typeof participants !== 'object' || 314 Object.keys(participants).length === 0; 315 var drop = age > DRAW_HARD_CAP_MS || (empty && age > DRAW_EMPTY_MS); 316 if (!drop) return; 317 if (!takeBudget(budget)) return; 318 deleted += 1; 319 return removePath(base).then(function () { 320 if (typeof roomCode === 'string' && roomCode.trim()) { 321 return removePath('drawTogether/roomCodes/' + roomCode.trim().toUpperCase()); 322 } 323 }); 324 }); 325 }); 326 }); 327 return chain.then(function () { 328 return deleted; 329 }); 330 }); 331 } 332 333 function sweepGolfRooms(now, budget) { 334 return shallowKeys('golf/rooms').then(function (keys) { 335 var deleted = 0; 336 var chain = Promise.resolve(); 337 keys.forEach(function (key) { 338 chain = chain.then(function () { 339 if (budget.left <= 0) return; 340 var base = 'golf/rooms/' + key; 341 return shallowKeys(base + '/players').then(function (playerIds) { 342 if (!playerIds.length) { 343 if (!takeBudget(budget)) return; 344 deleted += 1; 345 return removePath(base); 346 } 347 var newest = 0; 348 var pChain = Promise.resolve(); 349 playerIds.forEach(function (pid) { 350 pChain = pChain.then(function () { 351 return readField(base + '/players/' + pid + '/lastUpdate').then(function (t) { 352 var n = Number(t || 0); 353 if (n > newest) newest = n; 354 }); 355 }); 356 }); 357 return pChain.then(function () { 358 if (!newest || now - newest <= GOLF_IDLE_MS) return; 359 if (!takeBudget(budget)) return; 360 deleted += 1; 361 return removePath(base); 362 }); 363 }); 364 }); 365 }); 366 return chain.then(function () { 367 return deleted; 368 }); 369 }); 370 } 371 372 function sweepPetVisitors(now, budget) {
373 return shallowKeys('petSim/plaza/visitors').then(function (keys) { 374 var deleted = 0; 375 var chain = Promise.resolve(); 376 keys.forEach(function (key) { 377 chain = chain.then(function () { 378 if (budget.left <= 0) return; 379 var leaf = 'petSim/plaza/visitors/' + key; 380 return readField(leaf + '/lastSeen').then(function (lastSeen) { 381 var t = Number(lastSeen || 0); 382 if (t && now - t <= PET_STALE_MS) return; 383 if (!takeBudget(budget)) return; 384 deleted += 1; 385 return removePath(leaf); 386 }); 387 }); 388 }); 389 return chain.then(function () { 390 return deleted; 391 }); 392 }); 393 } 394 395 function runSweep() { 396 if (!db() && typeof fetch !== 'function') return Promise.resolve(0); 397 398 var now = Date.now(); 399 var budget = { left: MAX_DELETES_PER_RUN }; 400 var total = 0; 401 402 function add(n) { 403 total += Number(n) || 0; 404 } 405 406 return Promise.resolve() 407 .then(function () { 408 return sweepClassicRooms('soccerRooms', now, budget).then(add); 409 }) 410 .then(function () { 411 return sweepClassicRooms('plongMatches', now, budget).then(add); 412 }) 413 .then(function () { 414 return sweepClassicRooms('blackjackRooms', now, budget).then(add); 415 }) 416 .then(function () { 417 return sweepClassicRooms('golfRooms', now, budget).then(add); 418 }) 419 .then(function () { 420 return sweepClassicRooms('kablingo/rooms', now, budget).then(add); 421 }) 422 .then(function () { 423 return sweepCasinoRooms('casinoWarRooms', now, budget).then(add); 424 }) 425 .then(function () { 426 return sweepCasinoRooms('casinoRpsRooms', now, budget).then(add); 427 }) 428 .then(function () { 429 return sweepCasinoInvites(now, budget).then(add); 430 }) 431 .then(function () { 432 return sweepTriviaRooms(now, budget).then(add); 433 }) 434 .then(function () { 435 return sweepDrawTogether(now, budget).then(add); 436 }) 437 .then(function () { 438 return sweepGolfRooms(now, budget).then(add); 439 }) 440 .then(function () { 441 return sweepPetVisitors(now, budget).then(add); 442 }) 443 .then(function () { 444 emitLog('[StaleRoomSweeper] removed', total, 'node(s); budget left', budget.left); 445 return total; 446 }); 447 } 448 449 /** 450 * Throttled entry point for dashboard / SPA boot. 451 * @returns {Promise<number>} 452 */ 453 function runIfDue(force) { 454 if (running) return Promise.resolve(0); 455 var now = Date.now(); 456 if (!force && now - readThrottleAt() < THROTTLE_MS) { 457 return Promise.resolve(0); 458 } 459 running = true; 460 writeThrottleAt(now); 461 return runSweep() 462 .catch(function (err) { 463 emitLog('[StaleRoomSweeper] sweep failed', err && err.message ? err.message : err); 464 return 0; 465 }) 466 .then(function (n) { 467 running = false; 468 return n; 469 }); 470 } 471 472 /** 473 * Admin-only: delete pishock logs older than 7 days. 474 * Uses the Firebase SDK (admin already authenticated for reads). 475 * @returns {Promise<number>} 476 */ 477 function prunePishockLogs() { 478 var database = db(); 479 if (!database) return Promise.resolve(0); 480 var cutoff = Date.now() - PISHOCK_LOG_MAX_AGE_MS; 481 return database 482 .ref('admin/pishockLogs') 483 .orderByChild('timestamp') 484 .endAt(cutoff) 485 .once('value') 486 .then(function (snap) { 487 if (!snap.exists()) return 0; 488 var jobs = []; 489 snap.forEach(function (child) { 490 jobs.push(child.ref.remove().catch(function () {})); 491 }); 492 return Promise.all(jobs).then(function () { 493 if (jobs.length) { 494 emitLog('[StaleRoomSweeper] pruned', jobs.length, 'pishock log(s)'); 495 } 496 return jobs.length; 497 }); 498 }) 499 .catch(function (err) { 500 emitLog('[StaleRoomSweeper] pishock prune failed', err && err.message ? err.message : err); 501 return 0; 502 }); 503 } 504 505 global.StaleRoomSweeper = { 506 runIfDue: runIfDue, 507 prunePishockLogs: prunePishockLogs, 508 THROTTLE_MS: THROTTLE_MS, 509 PISHOCK_LOG_MAX_AGE_MS: PISHOCK_LOG_MAX_AGE_MS 510 }; 511})(typeof window !== 'undefined' ? window : this);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.