| 1 | /** |
| 2 | * Folios that cite code a change touched become possibly out of date, as |
| 3 | * Docs' pages do (src/staleness.ts, which calls this with the same change |
| 4 | * after recording pages). Once per folio and commit; the owner hears of |
| 5 | * it (which change, only if they can read the repository); open rooms |
| 6 | * ask their readers to look again; `folio.stale` goes out with a title |
| 7 | * only when the whole workspace can read the folio. |
| 8 | */ |
| 9 | import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts"; |
| 10 | |
| 11 | import { touchedPaths } from "../citations.ts"; |
| 12 | import type { Change, StaleEnv } from "../staleness.ts"; |
| 13 | import { slugOf } from "./list.ts"; |
| 14 | import { workspaceReadable } from "./access-store.ts"; |
| 15 | import { publishFolioEvent } from "./events.ts"; |
| 16 | import type { FolioRoom } from "./room.ts"; |
| 17 | |
| 18 | export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> }; |
| 19 | |
| 20 | /** Whether any live folio cites the repository. */ |
| 21 | export async function foliosCite(db: D1Database, repo: string): Promise<boolean> { |
| 22 | const row = await db.prepare("SELECT 1 AS yes FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL LIMIT 1").bind(repo).first<{ yes: number }>(); |
| 23 | return !!row; |
| 24 | } |
| 25 | |
| 26 | /** Records a change against every folio whose citations it touches; returns the folios newly made stale. */ |
| 27 | export async function recordFolioChanges(env: FolioStaleEnv, change: Change, now = new Date()): Promise<string[]> { |
| 28 | const db = env.DB; |
| 29 | const cited = ( |
| 30 | await db |
| 31 | .prepare("SELECT c.folio_id, c.path FROM folio_citations c JOIN folios f ON f.id = c.folio_id WHERE c.repo = ? AND f.trashed_at IS NULL") |
| 32 | .bind(change.repo) |
| 33 | .all<{ folio_id: string; path: string }>() |
| 34 | ).results; |
| 35 | const byFolio = new Map<string, { path: string }[]>(); |
| 36 | for (const row of cited) byFolio.set(row.folio_id, [...(byFolio.get(row.folio_id) ?? []), row]); |
| 37 | const fresh: string[] = []; |
| 38 | const hits = new Map<string, string[]>(); |
| 39 | for (const [folioId, rows] of byFolio) { |
| 40 | const touched = touchedPaths(rows, change.changed); |
| 41 | if (!touched.length) continue; |
| 42 | hits.set(folioId, touched); |
| 43 | const [inserted] = await db.batch<{ folio_id: string }>([ |
| 44 | db |
| 45 | .prepare( |
| 46 | "INSERT INTO folio_changes (folio_id, repo, repo_id, commit_sha, pull_number, pull_title, paths, detected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (folio_id, repo, commit_sha) DO NOTHING RETURNING folio_id", |
| 47 | ) |
| 48 | .bind(folioId, change.repo, change.repo_id, change.commit, change.pull?.number ?? null, change.pull?.title ?? null, JSON.stringify(touched), now.toISOString()), |
| 49 | ...(change.pull |
| 50 | ? [db.prepare("UPDATE folio_changes SET pull_number = ?, pull_title = ? WHERE folio_id = ? AND repo = ? AND commit_sha = ?").bind(change.pull.number, change.pull.title, folioId, change.repo, change.commit)] |
| 51 | : []), |
| 52 | ]); |
| 53 | if (inserted?.results.length) fresh.push(folioId); |
| 54 | } |
| 55 | if (fresh.length) await tellOf(env, change, fresh, hits); |
| 56 | return fresh; |
| 57 | } |
| 58 | |
| 59 | type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string }; |
| 60 | |
| 61 | async function tellOf(env: FolioStaleEnv, change: Change, ids: string[], hits: Map<string, string[]>): Promise<void> { |
| 62 | const db = env.DB; |
| 63 | const rows = (await db.prepare("SELECT id, workspace_id, kind, space_id, title, owner FROM folios WHERE id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(ids)).all<Row>()).results; |
| 64 | const slugs = await identityClient(env.IDENTITY) |
| 65 | .usernames([...new Set(rows.map((r) => r.workspace_id))]) |
| 66 | .catch(() => ({}) as Record<string, string>); |
| 67 | const ownerIds = [...new Set(rows.map((r) => r.owner).filter((k) => k.startsWith("user:")).map((k) => k.slice(5)))]; |
| 68 | const people = ownerIds.length ? await identityClient(env.IDENTITY).usersForAudience(ownerIds).catch(() => [] as User[]) : []; |
| 69 | const [namespace, name] = change.repo.split("/") as [string, string]; |
| 70 | const canRead = new Set<string>(); |
| 71 | if (env.REPOS) { |
| 72 | await Promise.all( |
| 73 | people.map(async (person) => { |
| 74 | const found = await reposClient(env.REPOS!) |
| 75 | .get({ namespace, name }, person) |
| 76 | .catch(() => null); |
| 77 | if (found?.ok) canRead.add(person.id); |
| 78 | }), |
| 79 | ); |
| 80 | } |
| 81 | const what = change.pull ? `${change.repo}#${change.pull.number}` : `${change.repo}@${change.commit.slice(0, 7)}`; |
| 82 | const notify = env.NOTIFY ? notifyClient(env.NOTIFY) : null; |
| 83 | for (const row of rows) { |
| 84 | const slug = slugs[row.workspace_id]; |
| 85 | if (!slug) continue; |
| 86 | const href = `/${slug}/-/artifacts/${slugOf(row.title, row.id)}`; |
| 87 | const paths = hits.get(row.id) ?? []; |
| 88 | const work: Promise<unknown>[] = []; |
| 89 | if (notify && row.owner.startsWith("user:")) { |
| 90 | const id = row.owner.slice(5); |
| 91 | const known = canRead.has(id); |
| 92 | work.push( |
| 93 | notify |
| 94 | .notify( |
| 95 | { user_id: id }, |
| 96 | { |
| 97 | id: `folio-stale:${row.id}:${change.commit}:${id}`, |
| 98 | kind: "inbox", |
| 99 | workspace: slug, |
| 100 | title: `${row.title || "Untitled"} may be out of date`, |
| 101 | body: known ? `${what} changed ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ` and ${paths.length - 3} more` : ""}` : "A change to code this cites was merged.", |
| 102 | href, |
| 103 | actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null }, |
| 104 | created_at: new Date().toISOString(), |
| 105 | }, |
| 106 | ) |
| 107 | .catch(() => undefined), |
| 108 | ); |
| 109 | } |
| 110 | if (env.FOLIOS) { |
| 111 | work.push( |
| 112 | env.FOLIOS.get(env.FOLIOS.idFromName(row.id)) |
| 113 | .notice({ type: "folio.staleness" }) |
| 114 | .catch(() => undefined), |
| 115 | ); |
| 116 | } |
| 117 | const open = await workspaceReadable(db, row.id).catch(() => false); |
| 118 | work.push( |
| 119 | publishFolioEvent( |
| 120 | env.EVENTS, |
| 121 | "folio.stale", |
| 122 | { |
| 123 | workspace: slug, |
| 124 | workspaceId: row.workspace_id, |
| 125 | folioId: row.id, |
| 126 | kind: row.kind, |
| 127 | spaceId: row.space_id, |
| 128 | title: open ? row.title : null, |
| 129 | repo: change.repo, |
| 130 | commit: change.commit, |
| 131 | pull: change.pull?.number ?? null, |
| 132 | paths, |
| 133 | owners: [row.owner], |
| 134 | }, |
| 135 | change.actor ? `user:${change.actor}` : null, |
| 136 | ), |
| 137 | ); |
| 138 | await Promise.all(work); |
| 139 | } |
| 140 | } |