char-slop/stars.hackery.site

git clone https://git.t4t.associates/char-slop/stars.hackery.site

Charlotte Somsyntactic pass: no casts, assertions, or void92004f9

main
8.2 KiB265 linesraw
1import { DatabaseSync } from "node:sqlite";
2import { setTimeout as sleep } from "node:timers/promises";
3import * as l from "@cerulea/lexicon";
4import { Hydrant } from "./hydrant.ts";
5import {
6  type AtUri,
7  atUri,
8  Follow,
9  parseAtUri,
10  Star,
11  starUrl,
12} from "./records.ts";
13
14export type IndexedStar = { uri: string; author: string };
15const PAGE_SIZE = 25;
16
17const EventId = l.object({ id: l.integerWith({ minimum: 0 }) });
18const RecordEvent = l.object({
19  type: l.literal("record"),
20  record: l.object({ did: l.string, collection: l.string, rkey: l.string }),
21});
22
23// Yields a socket's text messages until it closes or the signal aborts.
24// Messages queue while the consumer is busy, bounded so a flood can't
25// exhaust memory.
26async function* messages(
27  socket: WebSocket,
28  signal: AbortSignal,
29): AsyncGenerator<string> {
30  const queue: string[] = [];
31  let closed = false;
32  let wake = () => {};
33  const stop = () => {
34    closed = true;
35    socket.close();
36    wake();
37  };
38  socket.addEventListener("message", (event) => {
39    if (typeof event.data !== "string" || queue.length >= 4096) return stop();
40    queue.push(event.data);
41    wake();
42  });
43  socket.addEventListener("error", stop);
44  socket.addEventListener("close", stop);
45  signal.addEventListener("abort", stop, { once: true });
46  try {
47    while (!closed || queue.length) {
48      const next = queue.shift();
49      if (next !== undefined) yield next;
50      else await new Promise<void>((resolve) => wake = resolve);
51    }
52  } finally {
53    signal.removeEventListener("abort", stop);
54    stop();
55  }
56}
57
58export class Index {
59  private readonly viewers = new Set<string>();
60  private readonly pendingViewers = new Map<string, Promise<boolean>>();
61  connected = false;
62  ready = false;
63
64  constructor(readonly db: DatabaseSync, readonly hydrant: Hydrant) {
65    db.exec(`
66      CREATE TABLE IF NOT EXISTS stars (uri TEXT PRIMARY KEY, author TEXT NOT NULL, tid TEXT NOT NULL);
67      CREATE INDEX IF NOT EXISTS stars_order ON stars (tid DESC, uri DESC);
68      CREATE TABLE IF NOT EXISTS follows (uri TEXT PRIMARY KEY, viewer TEXT NOT NULL, subject TEXT NOT NULL);
69      CREATE INDEX IF NOT EXISTS follows_viewer ON follows (viewer, subject);
70      CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT NOT NULL);
71    `);
72  }
73
74  private transaction<T>(body: () => T): T {
75    this.db.exec("BEGIN IMMEDIATE");
76    try {
77      const result = body();
78      this.db.exec("COMMIT");
79      return result;
80    } catch (error) {
81      this.db.exec("ROLLBACK");
82      throw error;
83    }
84  }
85
86  // The last stream event applied.
87  private get cursor(): number {
88    const row = this.db.prepare(
89      "SELECT value FROM sync_state WHERE key = 'cursor'",
90    ).get();
91    return Number(row?.value ?? -1);
92  }
93  private set cursor(id: number) {
94    this.db.prepare("INSERT OR REPLACE INTO sync_state VALUES ('cursor', ?)")
95      .run(String(id));
96  }
97
98  // Mirrors a record's current state; a missing or invalid one is removed.
99  private mirror(uri: string, value: unknown): void {
100    const collection = parseAtUri(uri)?.collection;
101    if (collection === Star.id) this.star(uri, value);
102    else if (collection === Follow.id) this.follow(uri, value);
103  }
104
105  star(uri: string, value: unknown): void {
106    const parsed = Star.validate(value);
107    const at = parseAtUri(uri);
108    if (
109      parsed.success && at?.collection === Star.id && starUrl(parsed.value.url)
110    ) {
111      this.db.prepare("INSERT OR REPLACE INTO stars VALUES (?, ?, ?)").run(
112        uri,
113        at.did,
114        at.rkey,
115      );
116    } else this.removeStar(uri);
117  }
118  removeStar(uri: string): void {
119    this.db.prepare("DELETE FROM stars WHERE uri = ?").run(uri);
120  }
121
122  follow(uri: string, value: unknown): void {
123    const parsed = Follow.validate(value);
124    const at = parseAtUri(uri);
125    if (
126      parsed.success && at?.collection === Follow.id &&
127      parsed.value.subject !== at.did
128    ) {
129      this.db.prepare("INSERT OR REPLACE INTO follows VALUES (?, ?, ?)").run(
130        uri,
131        at.did,
132        parsed.value.subject,
133      );
134    } else this.removeFollow(uri);
135  }
136  removeFollow(uri: string): void {
137    this.db.prepare("DELETE FROM follows WHERE uri = ?").run(uri);
138  }
139
140  following(viewer: string, subject: string): string[] {
141    return this.db.prepare(
142      "SELECT uri FROM follows WHERE viewer = ? AND subject = ?",
143    ).all(viewer, subject).map((row) => String(row.uri));
144  }
145
146  // Fetches one row past the page to learn whether there is a next one.
147  page(
148    viewer: string,
149    before?: string,
150  ): { stars: IndexedStar[]; next?: string } {
151    const rows = this.db.prepare(`SELECT uri, author FROM stars WHERE
152      (author = ? OR author IN (SELECT subject FROM follows WHERE viewer = ?))
153      AND (? IS NULL OR (tid, uri) < (?, ?))
154      ORDER BY tid DESC, uri DESC LIMIT ${PAGE_SIZE + 1}`).all(
155      viewer,
156      viewer,
157      before ?? null,
158      before?.split("/").at(-1) ?? "",
159      before ?? "",
160    ).map((row) => ({ uri: String(row.uri), author: String(row.author) }));
161    const stars = rows.slice(0, PAGE_SIZE);
162    return {
163      stars,
164      next: rows.length > PAGE_SIZE ? stars.at(-1)?.uri : undefined,
165    };
166  }
167
168  // Whether the viewer's follows are known, preparing them on first sight.
169  ensureViewer(did: string): Promise<boolean> {
170    if (this.viewers.has(did)) return Promise.resolve(true);
171    const pending = this.pendingViewers.get(did);
172    if (pending) return pending;
173    const task = (async () => {
174      try {
175        await this.hydrant.track(did);
176        await this.reconcileFollows(did);
177        this.viewers.add(did);
178        return true;
179      } catch (error) {
180        console.error("Could not prepare viewer", did, error);
181        return false;
182      } finally {
183        this.pendingViewers.delete(did);
184      }
185    })();
186    this.pendingViewers.set(did, task);
187    return task;
188  }
189
190  async reconcileFollows(viewer: string): Promise<void> {
191    const follows = await Array.fromAsync(
192      this.hydrant.allRecords(viewer, Follow.id),
193    );
194    this.transaction(() => {
195      this.db.prepare("DELETE FROM follows WHERE viewer = ?").run(viewer);
196      for (const { uri, value } of follows) this.follow(uri, value);
197    });
198  }
199
200  async bootstrap(): Promise<void> {
201    for await (const did of this.hydrant.repos()) {
202      for await (
203        const { uri, value } of this.hydrant.allRecords(did, Star.id)
204      ) {
205        this.star(uri, value);
206      }
207    }
208    const sessions = this.db.prepare(
209      "SELECT DISTINCT did FROM browser_session WHERE expires > ?",
210    ).all(Date.now());
211    for (const row of sessions) await this.ensureViewer(String(row.did));
212    this.ready = true;
213  }
214
215  // Every star, and the follows of people who use this site.
216  private relevant({ did, collection }: AtUri): boolean {
217    return collection === Star.id ||
218      (collection === Follow.id && this.viewers.has(did));
219  }
220
221  async apply(raw: unknown): Promise<void> {
222    const event = EventId.validate(raw);
223    if (!event.success) throw new Error("Invalid Hydrant event");
224    const { id } = event.value;
225    if (id <= this.cursor) return;
226    const change = RecordEvent.validate(raw);
227    const uri = change.success && this.relevant(change.value.record)
228      ? atUri(change.value.record)
229      : undefined;
230    const current = uri ? await this.hydrant.record(uri) : undefined;
231    this.transaction(() => {
232      if (uri) this.mirror(uri, current?.value);
233      this.cursor = id;
234    });
235  }
236
237  async run(signal: AbortSignal): Promise<void> {
238    while (!signal.aborted) {
239      try {
240        if (!this.ready) await this.bootstrap();
241        await this.consume(this.hydrant.stream(this.cursor + 1), signal);
242      } catch (error) {
243        if (!signal.aborted) console.error("Hydrant sync failed", error);
244      }
245      if (!signal.aborted) await sleep(1000);
246    }
247  }
248
249  private async consume(url: URL, signal: AbortSignal): Promise<void> {
250    const socket = new WebSocket(url);
251    const deadline = setTimeout(() => socket.close(), 15000);
252    socket.addEventListener("open", () => {
253      clearTimeout(deadline);
254      this.connected = true;
255    }, { once: true });
256    try {
257      for await (const message of messages(socket, signal)) {
258        await this.apply(JSON.parse(message));
259      }
260    } finally {
261      clearTimeout(deadline);
262      this.connected = false;
263    }
264  }
265}