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