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.

83 linesCodeBlameRaw
1/**
2 * One repository's live topic, as a Durable Object named by its id: who
3 * is watching its pages now. A workspace's Work pages have one too, named
4 * `workspace:<slug>` (src/watch.ts `workspaceTopic`).
5 *
6 * A feed (src/feed.ts) adds its person when one of their tabs opens a page
7 * of the repository, and again before the watch runs out (REPO_WATCH_MS);
8 * the site checked first that they may read it. When something changes in
9 * the repository, the topic hands it to each of those feeds, which pass it
10 * to their tabs. A feed with no tab open any more, or no longer watching,
11 * is dropped, and so is a watch that ran out: someone who loses access
12 * stops hearing of the repository at the latest when theirs does.
13 *
14 * Like the room, the topic authorizes nothing: only the Worker and feeds
15 * reach it.
16 */
17import { DurableObject } from "cloudflare:workers";
18
19import type { RepoChange, WorkspaceChange } from "@g1t/contracts";
20
21import type { Feed } from "./feed.ts";
22import { MAX_WATCHERS, named } from "./watch.ts";
23
24export type TopicEnv = { FEEDS?: DurableObjectNamespace<Feed> };
25
26export class Topic extends DurableObject<TopicEnv> {
27 private readonly sql: SqlStorage;
28
29 constructor(ctx: DurableObjectState, env: TopicEnv) {
30 super(ctx, env);
31 this.sql = ctx.storage.sql;
32 this.sql.exec("CREATE TABLE IF NOT EXISTS erased (user_id TEXT PRIMARY KEY)");
33 this.sql.exec(`CREATE TABLE IF NOT EXISTS watchers (user_id TEXT PRIMARY KEY, repo TEXT NOT NULL, until INTEGER NOT NULL)`);
34 }
35
36 /** `userId` watches the repository, as `repo` (`owner/name`), until `until` (ms). */
37 async watch(userId: string, repo: string, until: number): Promise<void> {
38 if (this.sql.exec("SELECT 1 FROM erased WHERE user_id = ?", userId).toArray().length) return;
39 this.sql.exec("INSERT OR REPLACE INTO watchers (user_id, repo, until) VALUES (?, ?, ?)", userId, repo, until);
40 this.sql.exec("DELETE FROM watchers WHERE user_id NOT IN (SELECT user_id FROM watchers ORDER BY until DESC LIMIT ?)", MAX_WATCHERS);
41 }
42
43 async forget(userId: string): Promise<void> {
44 this.sql.exec("INSERT OR IGNORE INTO erased (user_id) VALUES (?)", userId);
45 await this.unwatch(userId);
46 }
47
48 /** `userId` stopped watching it. */
49 async unwatch(userId: string): Promise<void> {
50 this.sql.exec("DELETE FROM watchers WHERE user_id = ?", userId);
51 }
52
53 /** Hands `changes` to everyone watching now; returns how many feeds passed them on. */
54 async changed(repoId: string, changes: RepoChange[]): Promise<number> {
55 return this.tell(changes.length, (feed, watcher) => feed.repoChanged(repoId, named(changes, watcher.repo)));
56 }
57
58 /** A workspace's topic (`key`): hands its work items' `changes` to everyone watching its Work pages. */
59 async workspaceChanged(key: string, changes: WorkspaceChange[]): Promise<number> {
60 return this.tell(changes.length, (feed) => feed.workspaceChanged(key, changes));
61 }
62
63 private async tell(count: number, send: (feed: DurableObjectStub<Feed>, watcher: { user_id: string; repo: string }) => Promise<boolean>): Promise<number> {
64 const feeds = this.env.FEEDS;
65 if (!feeds || !count) return 0;
66 const now = Date.now();
67 this.sql.exec("DELETE FROM watchers WHERE until <= ?", now);
68 const watchers = this.sql.exec<{ user_id: string; repo: string }>("SELECT user_id, repo FROM watchers").toArray();
69 if (!watchers.length) return 0;
70 const results = await Promise.allSettled(watchers.map((watcher) => send(feeds.get(feeds.idFromName(watcher.user_id)), watcher)));
71 let told = 0;
72 results.forEach((result, i) => {
73 if (result.status === "rejected") {
74 console.error("notify: telling a feed of a repository failed", result.reason);
75 return;
76 }
77 if (result.value) told++;
78 // No tab open, or not watching any more: it asks again when it is.
79 else this.sql.exec("DELETE FROM watchers WHERE user_id = ?", watchers[i]!.user_id);
80 });
81 return told;
82 }
83}