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).
51 lines
2.1 KiB
TypeScript
51 lines
2.1 KiB
TypeScript
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;
|
|
}
|