fix(presence): reset stale users.status on boot; drop REST-login online write
users.status was only flipped back to offline by the WebSocket disconnect path (5s grace timer in ConnectionManager). Process exits (deploy/crash/OOM) lose those in-memory timers, freezing any non-offline row at its last value and making the user appear permanently online to friends and space co-members. Confirmed in production on the Pi instance: a user appeared online for ~3 days with no live socket. Add resetStalePresenceOnBoot() in utils/presenceBoot.ts and call it from index.ts after getDb()/seedDatabase() and before WebSocket route registration. The reset is federation-safe: it only updates rows where home_instance IS NULL (replicated stubs are projections of remote presence and must not be stomped) and is_deleted = 0 (tombstoned users are excluded from broadcasts). Also remove the redundant status='online' write from POST /api/auth/login. A successful REST login does not imply a live socket; the WS auth handshake is the single source of truth. Login alone could otherwise produce the same stuck-online row when a client logs in and never establishes a WS. Tests cover: locally-homed online/idle/dnd reset, replicated rows untouched, tombstoned rows untouched, idempotence, mixed populations. Updates docs/systems/activity-presence.md (Connect/Disconnect Flow, new Boot Reset section) and docs/systems/auth.md (login no longer mutates status).
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { and, isNull, ne, eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Reset orphaned presence state at server boot.
|
||||
*
|
||||
* `users.status` is only flipped back to `'offline'` by the WebSocket
|
||||
* disconnect path (`ConnectionManager.finalizeDisconnect` after a 5s grace
|
||||
* timer). When the server process exits — deploy, crash, OOM, kill — those
|
||||
* in-memory grace timers are lost and any rows currently set to `'online'`,
|
||||
* `'idle'`, or `'dnd'` stay frozen at that value forever, causing users to
|
||||
* appear permanently online to friends and space co-members until they next
|
||||
* connect.
|
||||
*
|
||||
* At boot, the in-memory `ConnectionManager` is empty by construction, so
|
||||
* any non-`offline` status row is by definition stale and safe to reset.
|
||||
*
|
||||
* Federation safety:
|
||||
* - The `users` table contains replicated user stubs for users whose home
|
||||
* instance is elsewhere (`home_instance` non-null). Their `status` is a
|
||||
* projection of remote presence, broadcast to us by their home instance,
|
||||
* and is NOT a function of our local WebSocket state. We must not touch
|
||||
* replicated rows — only reset rows where `home_instance IS NULL`.
|
||||
* - Soft-deleted (tombstoned) users have `is_deleted = 1` and are excluded
|
||||
* from presence broadcasts already; leave their stored status alone.
|
||||
*
|
||||
* Called once during server boot, after `getDb()` succeeds and before the
|
||||
* WebSocket handler is registered. Idempotent — re-running has no effect
|
||||
* once all locally-homed users are `'offline'`.
|
||||
*
|
||||
* @returns Number of rows reset (for logging / test assertions).
|
||||
*/
|
||||
export function resetStalePresenceOnBoot(): number {
|
||||
const db = getDb();
|
||||
const result = db.update(schema.users)
|
||||
.set({ status: 'offline' })
|
||||
.where(and(
|
||||
isNull(schema.users.homeInstance),
|
||||
eq(schema.users.isDeleted, 0),
|
||||
ne(schema.users.status, 'offline'),
|
||||
))
|
||||
.run();
|
||||
|
||||
// better-sqlite3's RunResult exposes `changes`; drizzle passes it through.
|
||||
const changes = (result as { changes?: number }).changes ?? 0;
|
||||
if (changes > 0) {
|
||||
console.log(`[presenceBoot] Reset ${changes} stale user status row(s) to 'offline'`);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
Reference in New Issue
Block a user