| 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 | */ |
| 17 | import { DurableObject } from "cloudflare:workers"; |
| 18 | |
| 19 | import type { RepoChange, WorkspaceChange } from "@g1t/contracts"; |
| 20 | |
| 21 | import type { Feed } from "./feed.ts"; |
| 22 | import { MAX_WATCHERS, named } from "./watch.ts"; |
| 23 | |
| 24 | export type TopicEnv = { FEEDS?: DurableObjectNamespace<Feed> }; |
| 25 | |
| 26 | export 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 | } |