All files / src/utils stale-room-reap.js

100% Statements 23/23
100% Branches 14/14
100% Functions 3/3
100% Lines 19/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122                                                    16x         16x               16x                                                 122x 27x 23x 5x   18x 18x 10x   8x                 18x 18x 144x         18x                                       13x 13x 13x     16x              
/**
 * Stale-room lazy reap — defense-in-depth for OWNER_AWAY room closure.
 *
 * When a room enters OWNER_AWAY state (owner gracefully or ungracefully
 * disconnects), the room must eventually close. Closure happens via three
 * paths, in descending order of latency:
 *
 *  1. RTDB ownerLeft signal (PRs A0-A2 of the cron-elim cluster) — the
 *     owner client arms an onDisconnect at room entry; the server-side
 *     listener consumes the signal within seconds of disconnect and
 *     transitions the room via owner-left-handler's decideOwnerLeftAction.
 *     This is the primary path.
 *  2. This lazy reap — any participant-triggered room mutation reaps the
 *     stale room inline as part of the transaction. Covers the residual
 *     case where the ownerLeft signal somehow missed (signal failed to
 *     fire, server-side retry exhausted, race conditions) AND a
 *     participant happens to touch the room afterwards.
 *  3. (Eliminated) The staleRooms cron polled every 5 min; deleted in
 *     cron-elim A4 once the event-driven signal was verified in production.
 *
 * The lazy reap and the ownerLeft handler share the same close predicate
 * for behaviour consistency:
 *   - state === 'OWNER_AWAY'
 *   - AND (no non-owner seated past grace || ownerLeftAt < (now - timeout))
 */
 
const { hasNonOwnerSeated, MAX_SEATS } = require('./room-auth');
 
// Aligns with the owner-left handler's effective timeout. Rooms closed by
// either path share the same `ownerLeftAt` cutoff so the close decision is
// indistinguishable to downstream consumers.
const STALE_ROOM_TIMEOUT_MS = 10 * 60 * 1000;
 
// Grace window for the "no holdouts" case. Without an explicit grace
// lazy reap (which has no tick rate — fires on every access) would close
// instantly on the first access, including the owner's own /owner-returned
// or a user joining within the natural 5-min window. The grace preserves
// owner-can-return-quickly and join-during-grace UX. Matches the historical
// 0-5 min effective grace from the deleted cron's tick rate.
const STALE_ROOM_NO_HOLDOUTS_GRACE_MS = 5 * 60 * 1000;
 
/**
 * Should this room be reaped right now?
 *
 * Semantics (mirrors the owner-left handler's decideOwnerLeftAction
 * predicate with the historical cron's tick-rate grace expressed
 * explicitly):
 *  - state must be OWNER_AWAY and ownerLeftAt must be set
 *  - the owner calling is never reaped (they're reclaiming the room
 *    via /owner-returned or any other mutation — the close-on-access
 *    pattern must NOT fight the state machine's OWNER_AWAY → ACTIVE
 *    transition)
 *  - "no holdouts" branch waits the grace window first (so a join in
 *    the first few minutes after owner leaves still succeeds, matching
 *    cron-tick behaviour)
 *  - "holdouts present" branch waits the full timeout
 *
 * @param {object} room
 * @param {number} nowMs - current time in ms-since-epoch
 * @param {string|number|null} [callerId] - the participant whose
 *   mutation triggered this check; the owner is never reaped against
 * @returns {boolean}
 */
function shouldReapStaleRoom(room, nowMs, callerId = null) {
  if (!room || room.state !== 'OWNER_AWAY') return false;
  if (!room.ownerLeftAt) return false;
  if (callerId !== null && callerId !== undefined && String(callerId) === String(room.ownerId)) {
    return false;
  }
  const ageMs = nowMs - Number(room.ownerLeftAt);
  if (!hasNonOwnerSeated(room)) {
    return ageMs >= STALE_ROOM_NO_HOLDOUTS_GRACE_MS;
  }
  return ageMs >= STALE_ROOM_TIMEOUT_MS;
}
 
/**
 * Build the close payload. Same shape as the owner-left handler's
 * applyOwnerLeftTx close branch and the /api/rooms/:id/close endpoint,
 * so a room transitions identically regardless of which path fired.
 */
function buildClosePayload(nowMs) {
  const emptySeat = { userId: null, state: 'EMPTY', isMuted: false };
  const emptySeats = {};
  for (let i = 0; i < MAX_SEATS; i++) emptySeats[String(i)] = { ...emptySeat };
  // `ownerLeftAt: null` matches the owner-left handler's close branch
  // and the /api/rooms/:id/close endpoint payload. Without it, a reaped
  // room would carry a stale OWNER_AWAY timestamp on a CLOSED row,
  // diverging from every other close path.
  return {
    state: 'CLOSED',
    closedAt: nowMs,
    seats: emptySeats,
    participantIds: [],
    ownerLeftAt: null,
  };
}
 
/**
 * Apply the close to the room within a Firestore transaction.
 *
 * @param {FirebaseFirestore.Transaction} t
 * @param {FirebaseFirestore.DocumentReference} roomRef
 * @param {object} room - current room data (used only to confirm we're
 *   acting on the right state; the close payload is independent)
 * @param {number} nowMs
 * @returns {object} the post-close room shape (same shape mutate() will see)
 */
function reapStaleRoomTx(t, roomRef, room, nowMs) {
  const payload = buildClosePayload(nowMs);
  t.update(roomRef, payload);
  return { ...room, ...payload };
}
 
module.exports = {
  STALE_ROOM_TIMEOUT_MS,
  STALE_ROOM_NO_HOLDOUTS_GRACE_MS,
  shouldReapStaleRoom,
  reapStaleRoomTx,
  buildClosePayload,
};