PageSourceSearch

https://pupwife.com/scripts/dependencies/staleRoomSweeper.js

js pupwife.com collected 2026-09-30 15:06:15 UTC 14,557 bytes, 511 lines download raw bytes

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.