import type { Database } from "bun:sqlite"; import type { Context, Hono } from "hono"; import { type EventRecord, SAVE_ID, type SaveFile, type SaveRecord } from "../bucket/repo.ts"; import { openPublicRepo, type PageContext } from "../pages/layout.ts"; import { filesAt, headId } from "./queries.ts"; const JSON_TYPE = "application/json; charset=UTF-8"; const TEXT_TYPE = "text/plain; charset=UTF-8"; // §9.2's name for the layout. Koh trims whitespace before it compares, so no trailing newline is // needed to agree with a bucket. const FORMAT = "koh-remote 1"; // A private repo and an unknown one answer alike, to a person and to Koh (§20). const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}'; const OBJECT_HASH = /^[0-9a-f]{64}$/; // The addendum's lifetimes — a save's record can still be rewritten by a strip, so a minute, and an // object never changes once written but a strip can take it away, so a year rather than immutable. // `private`, as the pages are: a repo can be turned private, and no shared cache may keep serving // what it fetched while the repo was public (§20). const SAVE_CACHE = "private, max-age=60"; const OBJECT_CACHE = "private, max-age=31536000"; const DAY_CACHE = "private, max-age=86400"; const NO_CACHE = "no-cache"; type SaveRow = { id: string; parent: string | null; message: string; created_at: number; machine: string; is_checkpoint: number; size_bytes: number; agent: string | null; session: string | null; restores: string | null; replayed_from: string | null; }; type EventRow = { id: string; kind: EventRecord["kind"]; save_id: string | null; path: string | null; reason: string | null; created_at: number; machine: string; }; const SAVE_COLUMNS = `id, parent, message, created_at, machine, is_checkpoint, size_bytes, agent, session, restores, replayed_from`; const EVENT_COLUMNS = "id, kind, save_id, path, reason, created_at, machine"; /** The one answer for a private repo, an unknown one, and a record the mirror does not hold. */ function miss(c: Context): Response { return c.body(NOT_FOUND, 404, { "Content-Type": JSON_TYPE, "Cache-Control": "no-store" }); } /** * §9.2's layout, read-only, for the repos a visitor can see: what `koh steal /` * fetches, so the paths, the bytes and the field names are Koh's, not Kepr's (A2, part 2). */ export function registerKohRemote(app: Hono, ctx: PageContext): void { const serve = (c: Context, name: string, body: (mirror: Database) => Response): Response => { const repo = openPublicRepo(ctx, name); return repo ? body(repo.mirror) : miss(c); }; const json = (c: Context, body: string, cache: string): Response => c.body(body, 200, { "Content-Type": JSON_TYPE, "Cache-Control": cache }); const text = (c: Context, body: string, cache: string): Response => c.body(body, 200, { "Content-Type": TEXT_TYPE, "Cache-Control": cache }); app.get("/:repo/koh/format", (c) => serve(c, c.req.param("repo"), () => text(c, FORMAT, DAY_CACHE)), ); app.get("/:repo/koh/head", (c) => serve(c, c.req.param("repo"), (mirror) => { const head = headId(mirror); return head ? text(c, head, NO_CACHE) : miss(c); }), ); // Checkpoints are served like any other save: the parent links of the saves around them need // them, even though the pages hide them (§21.4). app.get("/:repo/koh/saves/:id", (c) => serve(c, c.req.param("repo"), (mirror) => { const id = c.req.param("id"); if (!SAVE_ID.test(id)) return miss(c); const row = mirror .query(`SELECT ${SAVE_COLUMNS} FROM saves WHERE id = ?`) .get(id); return row ? json(c, saveRecord(mirror, row), SAVE_CACHE) : miss(c); }), ); // The object's stored bytes, zlib and all: `koh steal` inflates them itself and checks the hash. // Only objects a visible file refers to: the mirror keeps hidden files' objects for unhiding. app.get("/:repo/koh/objects/:hash", (c) => serve(c, c.req.param("repo"), (mirror) => { const hash = c.req.param("hash"); if (!OBJECT_HASH.test(hash)) return miss(c); const row = mirror .query<{ data: Uint8Array }, [string]>( `SELECT data FROM objects o WHERE hash = ? AND EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`, ) .get(hash); return row ? c.body(row.data, 200, { "Content-Type": "application/octet-stream", "Cache-Control": OBJECT_CACHE, }) : miss(c); }), ); // Koh lists `/saves` and `/objects` as well as its events: a steal walks the chain // it is given here, and fetches every object those saves name. Both lists follow the mirror, so // neither is cached. app.get("/:repo/koh/saves", (c) => serve(c, c.req.param("repo"), (mirror) => { const ids = mirror .query<{ id: string }, []>("SELECT id FROM saves ORDER BY id") .all() .map((row) => row.id); return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE); }), ); app.get("/:repo/koh/objects", (c) => serve(c, c.req.param("repo"), (mirror) => { const hashes = mirror .query<{ hash: string }, []>( `SELECT hash FROM objects o WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash) ORDER BY hash`, ) .all() .map((row) => row.hash); return text(c, hashes.length > 0 ? `${hashes.join("\n")}\n` : "", NO_CACHE); }), ); app.get("/:repo/koh/events", (c) => serve(c, c.req.param("repo"), (mirror) => { const ids = mirror .query<{ id: string }, []>("SELECT id FROM events ORDER BY id") .all() .map((row) => row.id); return text(c, ids.length > 0 ? `${ids.join("\n")}\n` : "", NO_CACHE); }), ); app.get("/:repo/koh/events/:id", (c) => serve(c, c.req.param("repo"), (mirror) => { const id = c.req.param("id"); if (!SAVE_ID.test(id)) return miss(c); const row = mirror .query(`SELECT ${EVENT_COLUMNS} FROM events WHERE id = ?`) .get(id); return row ? json(c, eventRecord(row), DAY_CACHE) : miss(c); }), ); // The whole layout in one response, so a steal costs one request rather than one per key: a // 2,071-save repo took an hour of round trips. Streamed, because it is as big as the repo. app.get("/:repo/koh/bundle", (c) => serve(c, c.req.param("repo"), (mirror) => c.body(stream(bundle(mirror)), 200, { "Content-Type": "application/octet-stream", "Cache-Control": NO_CACHE, }), ), ); } function stream(chunks: Generator): ReadableStream { return new ReadableStream({ pull(controller) { const next = chunks.next(); if (next.done) controller.close(); else controller.enqueue(next.value); }, // A client that hangs up leaves the generator part way through; this finalizes its queries. cancel() { chunks.return(undefined); }, }); } /** ` \n` and that many bytes per entry, keyed as the single routes are; `end 0` last. */ function* bundle(mirror: Database): Generator { const encoder = new TextEncoder(); function* entry(key: string, bytes: Uint8Array): Generator { yield encoder.encode(`${key} ${bytes.length}\n`); yield bytes; } // `prepare`, never the cached `query`: two bundles streaming at once would share one cursor and // split the rows between them. const saves = mirror.prepare(`SELECT ${SAVE_COLUMNS} FROM saves`); const events = mirror.prepare(`SELECT ${EVENT_COLUMNS} FROM events`); const objects = mirror.prepare<{ hash: string; data: Uint8Array }, []>( `SELECT hash, data FROM objects o WHERE EXISTS (SELECT 1 FROM save_files f WHERE f.hash = o.hash)`, ); try { for (const row of saves.iterate()) { yield* entry(`saves/${row.id}`, encoder.encode(saveRecord(mirror, row))); } for (const row of events.iterate()) { yield* entry(`events/${row.id}`, encoder.encode(eventRecord(row))); } for (const row of objects.iterate()) yield* entry(`objects/${row.hash}`, row.data); // A connection cut between two entries would otherwise pass for the whole repo. yield encoder.encode("end 0\n"); } finally { saves.finalize(); events.finalize(); objects.finalize(); } } // Field names and their order are Koh's, and every optional is an empty string, as `json2.encode` // writes it: `koh steal` decodes this straight into its own record. function saveRecord(mirror: Database, row: SaveRow): string { const record: SaveRecord = { id: row.id, parent: row.parent ?? "", message: row.message, created_at: row.created_at, machine: row.machine, is_checkpoint: row.is_checkpoint === 1, size_bytes: row.size_bytes, agent: row.agent ?? "", session: row.session ?? "", restores: row.restores ?? "", replayed_from: row.replayed_from ?? "", // Whatever the strips left: a path they removed is gone from the record too. files: filesAt(mirror, row.id).map( (file): SaveFile => ({ path: file.path, hash: file.hash, executable: file.executable }), ), }; return JSON.stringify(record); } function eventRecord(row: EventRow): string { const record: EventRecord = { id: row.id, kind: row.kind, save_id: row.save_id ?? "", path: row.path ?? "", reason: row.reason ?? "", created_at: row.created_at, machine: row.machine, }; return JSON.stringify(record); }