| 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 |
| 23 | .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") |
| 24 | .bind(repo) |
| 25 | .first<{ yes: number }>(); |
| 26 | return !!row; |
| 27 | } |
| 28 | |
| 29 | /** Records a change against every folio whose citations it touches; returns the folios newly made stale. */ |
| 30 | export async function recordFolioChanges(env: FolioStaleEnv, change: Change, now = new Date()): Promise<string[]> { |
| 31 | const db = env.DB; |
| 32 | const cited = ( |
| 33 | await db |
| 34 | .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") |
| 35 | .bind(change.repo) |
| 36 | .all<{ folio_id: string; path: string }>() |
| 37 | ).results; |
| 38 | const byFolio = new Map<string, { path: string }[]>(); |
| 39 | for (const row of cited) byFolio.set(row.folio_id, [...(byFolio.get(row.folio_id) ?? []), row]); |
| 40 | const fresh: string[] = []; |
| 41 | const hits = new Map<string, string[]>(); |
| 42 | for (const [folioId, rows] of byFolio) { |
| 43 | const touched = touchedPaths(rows, change.changed); |
| 44 | if (!touched.length) continue; |
| 45 | hits.set(folioId, touched); |
| 46 | const [inserted] = await db.batch<{ folio_id: string }>([ |
| 47 | db |
| 48 | .prepare( |
| 49 | "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", |
| 50 | ) |
| 51 | .bind( |
| 52 | folioId, |
| 53 | change.repo, |
| 54 | change.repo_id, |
| 55 | change.commit, |
| 56 | change.pull?.number ?? null, |
| 57 | change.pull?.title ?? null, |
| 58 | JSON.stringify(touched), |
| 59 | now.toISOString(), |
| 60 | ), |
| 61 | ...(change.pull |
| 62 | ? [ |
| 63 | db |
| 64 | .prepare("UPDATE folio_changes SET pull_number = ?, pull_title = ? WHERE folio_id = ? AND repo = ? AND commit_sha = ?") |
| 65 | .bind(change.pull.number, change.pull.title, folioId, change.repo, change.commit), |
| 66 | ] |
| 67 | : []), |
| 68 | ]); |
| 69 | if (inserted?.results.length) fresh.push(folioId); |
| 70 | } |
| 71 | if (fresh.length) await tellOf(env, change, fresh, hits); |
| 72 | return fresh; |
| 73 | } |
| 74 | |
| 75 | type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string }; |
| 76 | |
| 77 | async function tellOf(env: FolioStaleEnv, change: Change, ids: string[], hits: Map<string, string[]>): Promise<void> { |
| 78 | const db = env.DB; |
| 79 | const rows = ( |
| 80 | await db |
| 81 | .prepare("SELECT id, workspace_id, kind, space_id, title, owner FROM folios WHERE id IN (SELECT value FROM json_each(?))") |
| 82 | .bind(JSON.stringify(ids)) |
| 83 | .all<Row>() |
| 84 | ).results; |
| 85 | const slugs = await identityClient(env.IDENTITY) |
| 86 | .usernames([...new Set(rows.map((r) => r.workspace_id))]) |
| 87 | .catch(() => ({}) as Record<string, string>); |
| 88 | const ownerIds = [ |
| 89 | ...new Set( |
| 90 | rows |
| 91 | .map((r) => r.owner) |
| 92 | .filter((k) => k.startsWith("user:")) |
| 93 | .map((k) => k.slice(5)), |
| 94 | ), |
| 95 | ]; |
| 96 | const people = ownerIds.length |
| 97 | ? await identityClient(env.IDENTITY) |
| 98 | .usersForAudience(ownerIds) |
| 99 | .catch(() => [] as User[]) |
| 100 | : []; |
| 101 | const [namespace, name] = change.repo.split("/") as [string, string]; |
| 102 | const canRead = new Set<string>(); |
| 103 | if (env.REPOS) { |
| 104 | await Promise.all( |
| 105 | people.map(async (person) => { |
| 106 | const found = await reposClient(env.REPOS!) |
| 107 | .get({ namespace, name }, person) |
| 108 | .catch(() => null); |
| 109 | if (found?.ok) canRead.add(person.id); |
| 110 | }), |
| 111 | ); |
| 112 | } |
| 113 | const what = change.pull ? `${change.repo}#${change.pull.number}` : `${change.repo}@${change.commit.slice(0, 7)}`; |
| 114 | const notify = env.NOTIFY ? notifyClient(env.NOTIFY) : null; |
| 115 | for (const row of rows) { |
| 116 | const slug = slugs[row.workspace_id]; |
| 117 | if (!slug) continue; |
| 118 | const href = `/${slug}/-/artifacts/${slugOf(row.title, row.id)}`; |
| 119 | const paths = hits.get(row.id) ?? []; |
| 120 | const work: Promise<unknown>[] = []; |
| 121 | if (notify && row.owner.startsWith("user:")) { |
| 122 | const id = row.owner.slice(5); |
| 123 | const known = canRead.has(id); |
| 124 | work.push( |
| 125 | notify |
| 126 | .notify( |
| 127 | { user_id: id }, |
| 128 | { |
| 129 | id: `folio-stale:${row.id}:${change.commit}:${id}`, |
| 130 | kind: "inbox", |
| 131 | workspace: slug, |
| 132 | title: `${row.title || "Untitled"} may be out of date`, |
| 133 | body: known |
| 134 | ? `${what} changed ${paths.slice(0, 3).join(", ")}${paths.length > 3 ? ` and ${paths.length - 3} more` : ""}` |
| 135 | : "A change to code this cites was merged.", |
| 136 | href, |
| 137 | actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null }, |
| 138 | created_at: new Date().toISOString(), |
| 139 | }, |
| 140 | ) |
| 141 | .catch(() => undefined), |
| 142 | ); |
| 143 | } |
| 144 | if (env.FOLIOS) { |
| 145 | work.push( |
| 146 | env.FOLIOS.get(env.FOLIOS.idFromName(row.id)) |
| 147 | .notice({ type: "folio.staleness" }) |
| 148 | .catch(() => undefined), |
| 149 | ); |
| 150 | } |
| 151 | const open = await workspaceReadable(db, row.id).catch(() => false); |
| 152 | work.push( |
| 153 | publishFolioEvent( |
| 154 | env.EVENTS, |
| 155 | "folio.stale", |
| 156 | { |
| 157 | workspace: slug, |
| 158 | workspaceId: row.workspace_id, |
| 159 | folioId: row.id, |
| 160 | kind: row.kind, |
| 161 | spaceId: row.space_id, |
| 162 | title: open ? row.title : null, |
| 163 | repo: change.repo, |
| 164 | commit: change.commit, |
| 165 | pull: change.pull?.number ?? null, |
| 166 | paths, |
| 167 | owners: [row.owner], |
| 168 | }, |
| 169 | change.actor ? `user:${change.actor}` : null, |
| 170 | ), |
| 171 | ); |
| 172 | await Promise.all(work); |
| 173 | } |
| 174 | } |