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.

g1t.sh
111 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.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}