/** * The shared presence roster. * * Broadcast only ever needed *fan-out* to work across instances — a frame goes * out, whoever is connected receives it. Presence needs more than that, because * `presence_state` is a question ("who is in this document?") and a per-process * `Map` can only answer for the clients that happen to share a replica with the * asker. Two people editing the same scene through different pods would each * see an empty room while broadcasting cursors at each other perfectly. * * So presence gets one row per tracked client, in Postgres, readable by every * instance. Three consequences worth stating: * * - **The table is the roster; the in-process map is a cache of our own * clients.** Reads answer from the table when this store is active, so the * answer is the same whichever instance is asked. * * - **`last_seen` is the liveness signal, and it is already there.** The client * heartbeats presence every ~20 s against a 30 s window; the sweep that has * always reaped local stale entries now also reaps rows belonging to * instances that stopped writing — which is exactly what a crashed pod looks * like. Crash recovery is a property of the TTL, not a separate mechanism. * * - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete * is the one that announces the departures, so a stale client produces one * `presence_diff` for the cluster rather than one per replica. */ import { NodePgDatabase } from "drizzle-orm/node-postgres"; /** A tracked client, as any instance sees it. */ export interface PresenceRow { channel: string; clientId: string; state: Record; } export declare class ChannelPresenceStore { private readonly db; private readonly instanceId; private tablesReady; constructor(db: NodePgDatabase>, instanceId: string); /** * Create the roster table. Idempotent, and safe to run on every instance at * once. * * Written as separate contained steps rather than one straight sequence for * a reason that only bites with more than one replica, which is exactly the * deployment shape this table exists to serve: `CREATE … IF NOT EXISTS` * reads the catalog and then writes to it non-atomically, so peers booting * together collide, and the loser used to abandon everything after it — * including the trailing `REVOKE`. That revoke is the only thing keeping the * roster off the end-user role, so losing a boot race silently left the * whole channel roster readable by every signed-in user. * * `tablesReady` is now set from a probe of what exists, not from having been * the instance that created it. */ ensureTables(): Promise; /** Record (or refresh) a client's presence. */ track(channel: string, clientId: string, state: Record): Promise; /** Drop one client's presence in one channel. */ remove(channel: string, clientId: string): Promise; /** Drop a client from every channel — used when its socket closes. */ removeClient(clientId: string): Promise; /** The global roster for a channel. */ roster(channel: string): Promise>>; /** * Reap rows this instance is not responsible for and that have gone quiet. * * Own rows are excluded because the in-process sweep already handles them — * and handles them better, since it can tell "the socket is gone" from "the * heartbeat is late". What is left is precisely the interesting case: rows * written by an instance that is no longer writing. * * Returns what was removed, so the caller can announce it. */ sweepStale(ttlMs: number): Promise; /** * Remove every row this instance owns. Called on graceful shutdown so a * rolling deploy does not leave a TTL window of ghosts in every roster. */ removeInstance(): Promise; }