Skip to content
140 linesCodeBlameRaw
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 */
9import { identityClient, notifyClient, reposClient, type User } from "@g1t/contracts";
10
11import { touchedPaths } from "../citations.ts";
12import type { Change, StaleEnv } from "../staleness.ts";
13import { slugOf } from "./list.ts";
14import { workspaceReadable } from "./access-store.ts";
15import { publishFolioEvent } from "./events.ts";
16import type { FolioRoom } from "./room.ts";
17
18export type FolioStaleEnv = StaleEnv & { FOLIOS?: DurableObjectNamespace<FolioRoom> };
19
20/** Whether any live folio cites the repository. */
21export 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. */
27export 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
59type Row = { id: string; workspace_id: string; kind: "doc" | "slides" | "design" | "dashboard"; space_id: string | null; title: string; owner: string };
60
61async 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}