feat(db): track all room activity types for expiry

Amolith created

Room expiry now tracks ALL activity, not just creation timestamps:
- Voting and unvoting
- Resetting votes
- Editing items
- Deleting items
- Setting room title
- Breaking ties

Added last_activity column to rooms table (schema v3β†’v4). All
activity operations now update this timestamp. deleteInactiveRooms
simplified to use last_activity directly instead of complex joins.

Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

db.ts     | 56 +++++++++++++++++++++++++++++++++++++++++++++-----------
server.ts |  2 ++
2 files changed, 47 insertions(+), 11 deletions(-)

Detailed changes

db.ts πŸ”—

@@ -169,13 +169,33 @@ if (currentVersion < 1) {
     console.error('❌ Migration failed:', err);
     throw err;
   }
+} else if (currentVersion === 3) {
+  console.log('πŸ”„ Migrating database schema from version 3 to 4...');
+  
+  try {
+    db.exec('BEGIN');
+    
+    // Add last_activity column to rooms table, default to created_at
+    db.exec('ALTER TABLE rooms ADD COLUMN last_activity INTEGER');
+    db.exec('UPDATE rooms SET last_activity = created_at');
+    
+    db.exec('PRAGMA user_version = 4');
+    db.exec('COMMIT');
+    
+    console.log('βœ… Migration from v3 to v4 complete');
+  } catch (err) {
+    db.exec('ROLLBACK');
+    console.error('❌ Migration failed:', err);
+    throw err;
+  }
 } else {
   // Plant the schema if soil is fresh (for new databases)
   db.exec(`
     CREATE TABLE IF NOT EXISTS rooms (
       code TEXT PRIMARY KEY NOT NULL,
       created_at INTEGER DEFAULT (unixepoch()),
-      title TEXT
+      title TEXT,
+      last_activity INTEGER DEFAULT (unixepoch())
     );
     
     CREATE TABLE IF NOT EXISTS items (
@@ -247,6 +267,7 @@ export function addItems(roomCode: string, items: Array<{id: string, text: strin
     for (const item of items) {
       stmt.run(item.id, roomCode, item.text);
     }
+    db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode);
     db.exec('COMMIT');
   } catch (err) {
     db.exec('ROLLBACK');
@@ -255,27 +276,43 @@ export function addItems(roomCode: string, items: Array<{id: string, text: strin
 }
 
 export function upsertVote(itemId: string, userId: string, voteType: string) {
+  const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined;
   db.prepare(`
     INSERT INTO votes (item_id, user_id, vote_type) 
     VALUES (?, ?, ?)
     ON CONFLICT (item_id, user_id) 
     DO UPDATE SET vote_type = excluded.vote_type
   `).run(itemId, userId, voteType);
+  if (roomCode) {
+    db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code);
+  }
 }
 
 export function deleteVote(itemId: string, userId: string) {
+  const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined;
   db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?').run(itemId, userId);
+  if (roomCode) {
+    db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code);
+  }
 }
 
 export function updateItemText(itemId: string, text: string) {
+  const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined;
   db.prepare('UPDATE items SET text = ? WHERE id = ?').run(text, itemId);
+  if (roomCode) {
+    db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code);
+  }
 }
 
 export function deleteItem(itemId: string) {
+  const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined;
   // Delete votes first (foreign key constraint)
   db.prepare('DELETE FROM votes WHERE item_id = ?').run(itemId);
   // Delete the item
   db.prepare('DELETE FROM items WHERE id = ?').run(itemId);
+  if (roomCode) {
+    db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code);
+  }
 }
 
 export function resetVotes(roomCode: string) {
@@ -285,10 +322,15 @@ export function resetVotes(roomCode: string) {
       SELECT id FROM items WHERE room_code = ?
     )
   `).run(roomCode);
+  db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode);
 }
 
 export function updateRoomTitle(roomCode: string, title: string | null) {
-  db.prepare('UPDATE rooms SET title = ? WHERE code = ?').run(title, roomCode);
+  db.prepare('UPDATE rooms SET title = ?, last_activity = unixepoch() WHERE code = ?').run(title, roomCode);
+}
+
+export function touchRoomActivity(roomCode: string) {
+  db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode);
 }
 
 export function getItemsWithVotes(roomCode: string) {
@@ -305,17 +347,9 @@ export function getItemsWithVotes(roomCode: string) {
 export function deleteInactiveRooms(daysInactive: number = 30): number {
   const cutoffTimestamp = Math.floor(Date.now() / 1000) - (daysInactive * 24 * 60 * 60);
   
-  // Find rooms where the most recent activity (item or vote) is older than the cutoff
   const result = db.prepare(`
     DELETE FROM rooms
-    WHERE code IN (
-      SELECT r.code
-      FROM rooms r
-      LEFT JOIN items i ON r.code = i.room_code
-      LEFT JOIN votes v ON i.id = v.item_id
-      GROUP BY r.code
-      HAVING COALESCE(MAX(i.created_at), MAX(v.created_at), r.created_at) < ?
-    )
+    WHERE last_activity < ?
   `).run(cutoffTimestamp);
   
   return result.changes || 0;

server.ts πŸ”—

@@ -343,6 +343,8 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) {
           const randomIndex = crypto.getRandomValues(new Uint32Array(1))[0] % tiedItems.length;
           const chosen = tiedItems[randomIndex];
           
+          db.touchRoomActivity(roomCode);
+          
           broadcast(roomCode, {
             type: 'tie_broken',
             itemId: chosen.id,