| 1 | /** |
| 2 | * One workspace's presence room, as a Durable Object named by its slug. |
| 3 | * |
| 4 | * Each member's feed (src/feed.ts) tells the room how they show whenever |
| 5 | * that changes: presence, status, Do Not Disturb. The room keeps the |
| 6 | * latest word from each, in its own SQLite storage, and passes every |
| 7 | * change on to the feeds of the members who are online now, which send it |
| 8 | * down their tabs open in this workspace. Members who are offline hear |
| 9 | * nothing; they read everyone afresh when a tab connects (`report` with |
| 10 | * `snapshot`). |
| 11 | * |
| 12 | * The room authorizes nothing: only feeds reach it, and a feed reports to |
| 13 | * the workspaces the site said its person belongs to. |
| 14 | */ |
| 15 | import { DurableObject } from "cloudflare:workers"; |
| 16 | |
| 17 | import type { PresenceEntry } from "@g1t/contracts"; |
| 18 | |
| 19 | import type { Feed } from "./feed.ts"; |
| 20 | import { liveEntry, sameEntry } from "./presence.ts"; |
| 21 | |
| 22 | export type RoomEnv = { FEEDS?: DurableObjectNamespace<Feed> }; |
| 23 | |
| 24 | /** The most members a room keeps. */ |
| 25 | const MAX_PEOPLE = 5000; |
| 26 | |
| 27 | export class Room extends DurableObject<RoomEnv> { |
| 28 | private readonly sql: SqlStorage; |
| 29 | |
| 30 | constructor(ctx: DurableObjectState, env: RoomEnv) { |
| 31 | super(ctx, env); |
| 32 | this.sql = ctx.storage.sql; |
| 33 | this.sql.exec("CREATE TABLE IF NOT EXISTS erased (user_id TEXT PRIMARY KEY)"); |
| 34 | this.sql.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)"); |
| 35 | this.sql.exec(`CREATE TABLE IF NOT EXISTS people (user_id TEXT PRIMARY KEY, at INTEGER NOT NULL, presence TEXT NOT NULL, json TEXT NOT NULL)`); |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * The workspace was purged: the people it knows, for their feeds to be |
| 40 | * cleared. Its slug can be made again, so the mark is a time: a word |
| 41 | * said before it, arriving late, is refused; a new workspace's is not. |
| 42 | */ |
| 43 | async eraseMembers(): Promise<string[]> { |
| 44 | this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('purged_at', ?)", String(Date.now())); |
| 45 | return this.sql.exec<{ user_id: string }>("SELECT user_id FROM people").toArray().map(r => r.user_id); |
| 46 | } |
| 47 | |
| 48 | /** Takes one person off the purged workspace's list, once their feed is cleared. Not a ban: they may join a new one. */ |
| 49 | async dropPerson(userId: string): Promise<void> { |
| 50 | this.sql.exec("DELETE FROM people WHERE user_id = ?", userId); |
| 51 | } |
| 52 | |
| 53 | /** The person was erased, for good: nothing they say is kept again. */ |
| 54 | async forget(userId: string): Promise<void> { |
| 55 | this.sql.exec("INSERT OR IGNORE INTO erased (user_id) VALUES (?)", userId); |
| 56 | this.sql.exec("DELETE FROM people WHERE user_id = ?", userId); |
| 57 | } |
| 58 | |
| 59 | private all(): PresenceEntry[] { |
| 60 | const now = Date.now(); |
| 61 | return this.sql |
| 62 | .exec<{ json: string }>("SELECT json FROM people ORDER BY at DESC LIMIT ?", MAX_PEOPLE) |
| 63 | .toArray() |
| 64 | .map((row) => liveEntry(JSON.parse(row.json) as PresenceEntry, now)); |
| 65 | } |
| 66 | |
| 67 | private find(userId: string): PresenceEntry | null { |
| 68 | const row = this.sql.exec<{ json: string }>("SELECT json FROM people WHERE user_id = ?", userId).toArray()[0]; |
| 69 | return row ? (JSON.parse(row.json) as PresenceEntry) : null; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * A member's latest word: kept, and passed to everyone online when it |
| 74 | * changes how they show. With `snapshot`, everyone known comes back, for |
| 75 | * a tab that just connected. |
| 76 | */ |
| 77 | async report(workspace: string, entry: PresenceEntry, snapshot = false): Promise<PresenceEntry[] | null> { |
| 78 | const purgedAt = Number(this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'purged_at'").toArray()[0]?.value ?? 0); |
| 79 | if (entry.at <= purgedAt || this.sql.exec("SELECT 1 FROM erased WHERE user_id = ?", entry.user_id).toArray().length) return snapshot ? this.all() : null; |
| 80 | const before = this.find(entry.user_id); |
| 81 | // An older word, arriving late, changes nothing. |
| 82 | if (!before || before.at <= entry.at) { |
| 83 | this.sql.exec( |
| 84 | "INSERT OR REPLACE INTO people (user_id, at, presence, json) VALUES (?, ?, ?, ?)", |
| 85 | entry.user_id, |
| 86 | entry.at, |
| 87 | entry.presence, |
| 88 | JSON.stringify(entry), |
| 89 | ); |
| 90 | if (!sameEntry(before, entry)) this.ctx.waitUntil(this.tell(workspace, entry)); |
| 91 | } |
| 92 | return snapshot ? this.all() : null; |
| 93 | } |
| 94 | |
| 95 | /** Everyone known, as they show now. */ |
| 96 | async people(): Promise<PresenceEntry[]> { |
| 97 | return this.all(); |
| 98 | } |
| 99 | |
| 100 | /** Passes a change to every member online now, the one it is about included (their other tabs). */ |
| 101 | private async tell(workspace: string, entry: PresenceEntry): Promise<void> { |
| 102 | const feeds = this.env.FEEDS; |
| 103 | if (!feeds) return; |
| 104 | const online = this.sql |
| 105 | .exec<{ user_id: string }>("SELECT user_id FROM people WHERE presence != 'offline' OR user_id = ?", entry.user_id) |
| 106 | .toArray() |
| 107 | .map((row) => row.user_id); |
| 108 | const results = await Promise.allSettled(online.map((id) => feeds.get(feeds.idFromName(id)).presence(workspace, [entry]))); |
| 109 | for (const result of results) if (result.status === "rejected") console.error("notify: telling a feed of presence failed", result.reason); |
| 110 | } |
| 111 | } |