Skip to content
68 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 watchers (user_id TEXT PRIMARY KEY, repo TEXT NOT NULL, until INTEGER NOT NULL)`);
32 }
33
34 /** `userId` watches the repository, as `repo` (`owner/name`), until `until` (ms). */
35 async watch(userId: string, repo: string, until: number): Promise<void> {
36 this.sql.exec("INSERT OR REPLACE INTO watchers (user_id, repo, until) VALUES (?, ?, ?)", userId, repo, until);
37 this.sql.exec("DELETE FROM watchers WHERE user_id NOT IN (SELECT user_id FROM watchers ORDER BY until DESC LIMIT ?)", MAX_WATCHERS);
38 }
39
40 /** `userId` stopped watching it. */
41 async unwatch(userId: string): Promise<void> {
42 this.sql.exec("DELETE FROM watchers WHERE user_id = ?", userId);
43 }
44
45 /** Hands `changes` to everyone watching now; returns how many feeds passed them on. */
46 async changed(repoId: string, changes: RepoChange[]): Promise<number> {
47 const feeds = this.env.FEEDS;
48 if (!feeds || !changes.length) return 0;
49 const now = Date.now();
50 this.sql.exec("DELETE FROM watchers WHERE until <= ?", now);
51 const watchers = this.sql.exec<{ user_id: string; repo: string }>("SELECT user_id, repo FROM watchers").toArray();
52 if (!watchers.length) return 0;
53 const results = await Promise.allSettled(
54 watchers.map((watcher) => feeds.get(feeds.idFromName(watcher.user_id)).repoChanged(repoId, named(changes, watcher.repo))),
55 );
56 let told = 0;
57 results.forEach((result, i) => {
58 if (result.status === "rejected") {
59 console.error("notify: telling a feed of a repository failed", result.reason);
60 return;
61 }
62 if (result.value) told++;
63 // No tab open, or not watching any more: it asks again when it is.
64 else this.sql.exec("DELETE FROM watchers WHERE user_id = ?", watchers[i]!.user_id);
65 });
66 return told;
67 }
68}