Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

114 linesCodeBlameRaw
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 */
15import { DurableObject } from "cloudflare:workers";
16
17import type { PresenceEntry } from "@g1t/contracts";
18
19import type { Feed } from "./feed.ts";
20import { liveEntry, sameEntry } from "./presence.ts";
21
22export type RoomEnv = { FEEDS?: DurableObjectNamespace<Feed> };
23
24/** The most members a room keeps. */
25const MAX_PEOPLE = 5000;
26
27export 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
46 .exec<{ user_id: string }>("SELECT user_id FROM people")
47 .toArray()
48 .map((r) => r.user_id);
49 }
50
51 /** Takes one person off the purged workspace's list, once their feed is cleared. Not a ban: they may join a new one. */
52 async dropPerson(userId: string): Promise<void> {
53 this.sql.exec("DELETE FROM people WHERE user_id = ?", userId);
54 }
55
56 /** The person was erased, for good: nothing they say is kept again. */
57 async forget(userId: string): Promise<void> {
58 this.sql.exec("INSERT OR IGNORE INTO erased (user_id) VALUES (?)", userId);
59 this.sql.exec("DELETE FROM people WHERE user_id = ?", userId);
60 }
61
62 private all(): PresenceEntry[] {
63 const now = Date.now();
64 return this.sql
65 .exec<{ json: string }>("SELECT json FROM people ORDER BY at DESC LIMIT ?", MAX_PEOPLE)
66 .toArray()
67 .map((row) => liveEntry(JSON.parse(row.json) as PresenceEntry, now));
68 }
69
70 private find(userId: string): PresenceEntry | null {
71 const row = this.sql.exec<{ json: string }>("SELECT json FROM people WHERE user_id = ?", userId).toArray()[0];
72 return row ? (JSON.parse(row.json) as PresenceEntry) : null;
73 }
74
75 /**
76 * A member's latest word: kept, and passed to everyone online when it
77 * changes how they show. With `snapshot`, everyone known comes back, for
78 * a tab that just connected.
79 */
80 async report(workspace: string, entry: PresenceEntry, snapshot = false): Promise<PresenceEntry[] | null> {
81 const purgedAt = Number(this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'purged_at'").toArray()[0]?.value ?? 0);
82 if (entry.at <= purgedAt || this.sql.exec("SELECT 1 FROM erased WHERE user_id = ?", entry.user_id).toArray().length) return snapshot ? this.all() : null;
83 const before = this.find(entry.user_id);
84 // An older word, arriving late, changes nothing.
85 if (!before || before.at <= entry.at) {
86 this.sql.exec(
87 "INSERT OR REPLACE INTO people (user_id, at, presence, json) VALUES (?, ?, ?, ?)",
88 entry.user_id,
89 entry.at,
90 entry.presence,
91 JSON.stringify(entry),
92 );
93 if (!sameEntry(before, entry)) this.ctx.waitUntil(this.tell(workspace, entry));
94 }
95 return snapshot ? this.all() : null;
96 }
97
98 /** Everyone known, as they show now. */
99 async people(): Promise<PresenceEntry[]> {
100 return this.all();
101 }
102
103 /** Passes a change to every member online now, the one it is about included (their other tabs). */
104 private async tell(workspace: string, entry: PresenceEntry): Promise<void> {
105 const feeds = this.env.FEEDS;
106 if (!feeds) return;
107 const online = this.sql
108 .exec<{ user_id: string }>("SELECT user_id FROM people WHERE presence != 'offline' OR user_id = ?", entry.user_id)
109 .toArray()
110 .map((row) => row.user_id);
111 const results = await Promise.allSettled(online.map((id) => feeds.get(feeds.idFromName(id)).presence(workspace, [entry])));
112 for (const result of results) if (result.status === "rejected") console.error("notify: telling a feed of presence failed", result.reason);
113 }
114}