Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 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 | } |