| 1 | /** |
| 2 | * What went live, and when: Home's "Live 9 min after merge". |
| 3 | * |
| 4 | * When a production deployment succeeds (`deployment_status.created` |
| 5 | * with `success` in a production environment: a g1t.page build, or one a |
| 6 | * workflow or the API reported, since builds are published into the same |
| 7 | * model), its commit's history is read back from repos and every |
| 8 | * commit in it that no production deployment shipped before is written |
| 9 | * down as shipped by this one (`shipped_commits`, migration 0017). A |
| 10 | * commit's first production deployment is the one that took it live: a |
| 11 | * rollback to an older commit ships nothing new, and a deployment's event |
| 12 | * delivered twice is walked once (`shipped_walks`). |
| 13 | * |
| 14 | * Asking (`shipped`) reads only what was written, so a page never walks |
| 15 | * history. A change not shipped is `deploying` while a production |
| 16 | * deployment after it is on its way, `failed` when the latest after it |
| 17 | * failed, and `waiting` otherwise. A change that landed before recording |
| 18 | * began for its repository is matched to the first production deployment |
| 19 | * that succeeded after it instead. |
| 20 | */ |
| 21 | import type { |
| 22 | Commit, |
| 23 | DeploymentStatus, |
| 24 | RepoDeployment, |
| 25 | RepoPath, |
| 26 | Result, |
| 27 | ShipState, |
| 28 | ShippedAsk, |
| 29 | ShippedCommit, |
| 30 | ShippedRepo, |
| 31 | User, |
| 32 | Viewer, |
| 33 | } from "@g1t/contracts"; |
| 34 | |
| 35 | /** Reads a commit's history back, newest first (repos' `log`). */ |
| 36 | export type ReadLog = (repo: RepoPath, actor: User, ref: string, limit: number) => Promise<Result<Pick<Commit, "hash" | "parents">[]>>; |
| 37 | |
| 38 | /** Commits read back per page of history, and the most pages one deployment walks. */ |
| 39 | export const WALK_PAGE = 200; |
| 40 | export const WALK_PAGES = 5; |
| 41 | /** The most repositories, and commits in all, one `shipped` call asks about. */ |
| 42 | export const MAX_REPOS = 20; |
| 43 | export const MAX_COMMITS = 300; |
| 44 | |
| 45 | /** A production deployment that went live, as its event says. */ |
| 46 | export type Shipment = { |
| 47 | deploymentId: string; |
| 48 | repoId: string; |
| 49 | repo: RepoPath; |
| 50 | sha: string; |
| 51 | environment: string; |
| 52 | deployedAt: string; |
| 53 | /** Its page on the site. */ |
| 54 | url: string | null; |
| 55 | }; |
| 56 | |
| 57 | /** |
| 58 | * The commits of one page of history this deployment ships: those not |
| 59 | * known to be shipped already, each once. Whether to read the next page: |
| 60 | * only when this page was full and held nothing shipped before, so the |
| 61 | * walk stops at the last deployment's history. |
| 62 | */ |
| 63 | export function walkPage(page: Pick<Commit, "hash" | "parents">[], known: ReadonlySet<string>, full: boolean): { ship: string[]; more: boolean; next: string | null } { |
| 64 | const ship = [...new Set(page.map((commit) => commit.hash).filter((hash) => !known.has(hash)))]; |
| 65 | const reachedKnown = page.some((commit) => known.has(commit.hash)); |
| 66 | const last = page.at(-1); |
| 67 | const next = last?.parents[0] ?? null; |
| 68 | return { ship, more: full && !reachedKnown && next != null, next }; |
| 69 | } |
| 70 | |
| 71 | /** A production attempt after a change landed, as either table keeps it. */ |
| 72 | export type Attempt = { at: string; outcome: "live" | "running" | "failed"; url: string | null; environment: string }; |
| 73 | |
| 74 | /** |
| 75 | * A change's state from what was recorded: shipped (its first production |
| 76 | * deployment), else from the production attempts after it landed (oldest |
| 77 | * first). `recordingStart` is when the repository's first walk went live: |
| 78 | * a change that landed before it was never walked, so the first attempt |
| 79 | * after it that went live shipped it. |
| 80 | */ |
| 81 | export function shipStateOf( |
| 82 | ask: ShippedAsk, |
| 83 | shipped: { deployed_at: string; url: string | null; environment: string } | null, |
| 84 | attempts: Attempt[], |
| 85 | recordingStart: string | null, |
| 86 | ): ShippedCommit { |
| 87 | const base = { sha: ask.sha }; |
| 88 | const after = attempts.filter((attempt) => attempt.at >= ask.landed_at); |
| 89 | // Landed before anything was walked: the first walk wrote its history |
| 90 | // down as of then, so the first deployment after it that went live is nearer. |
| 91 | if (recordingStart == null || ask.landed_at < recordingStart) { |
| 92 | const live = after.find((attempt) => attempt.outcome === "live"); |
| 93 | if (live) return { ...base, state: "live", deployed_at: live.at, url: live.url, environment: live.environment }; |
| 94 | } |
| 95 | if (shipped) return { ...base, state: "live", deployed_at: shipped.deployed_at, url: shipped.url, environment: shipped.environment }; |
| 96 | const running = after.find((attempt) => attempt.outcome === "running"); |
| 97 | if (running) return { ...base, state: "deploying", deployed_at: null, url: running.url, environment: running.environment }; |
| 98 | const latest = after.at(-1); |
| 99 | const state: ShipState = latest?.outcome === "failed" ? "failed" : "waiting"; |
| 100 | return { ...base, state, deployed_at: null, url: state === "failed" ? (latest?.url ?? null) : null, environment: latest?.environment ?? null }; |
| 101 | } |
| 102 | |
| 103 | /** A g1t.page build's outcome. */ |
| 104 | function buildOutcome(status: string): Attempt["outcome"] | null { |
| 105 | if (status === "ready" || status === "replaced" || status === "down") return "live"; |
| 106 | if (status === "queued" || status === "building") return "running"; |
| 107 | if (status === "failed") return "failed"; |
| 108 | return null; |
| 109 | } |
| 110 | |
| 111 | /** A reported deployment's outcome: `inactive` is a success a newer one replaced. */ |
| 112 | function reportedOutcome(state: string): Attempt["outcome"] | null { |
| 113 | if (state === "success" || state === "inactive") return "live"; |
| 114 | if (state === "queued" || state === "in_progress" || state === "pending") return "running"; |
| 115 | if (state === "failure" || state === "error") return "failed"; |
| 116 | return null; |
| 117 | } |
| 118 | |
| 119 | /** A site path from an address that may be the site's own, absolute. */ |
| 120 | export function sitePath(url: string | null | undefined, site: string): string | null { |
| 121 | if (!url) return null; |
| 122 | if (url.startsWith("/")) return url; |
| 123 | if (site && url.startsWith(site)) return url.slice(site.length) || "/"; |
| 124 | return null; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * The shipment a `deployment_status.created` event is, if it is one: a |
| 129 | * success in a production environment. A g1t.page build links to its |
| 130 | * page, a reported deployment to its workflow run, else to the |
| 131 | * repository's deployments. |
| 132 | */ |
| 133 | export function shipmentOf( |
| 134 | data: { repoId: string; deployment: Pick<RepoDeployment, "id" | "sha" | "environment" | "production_environment" | "source" | "project" | "run_url" | "updated_at">; deploymentStatus: Pick<DeploymentStatus, "state" | "created_at"> }, |
| 135 | repo: RepoPath, |
| 136 | site: string, |
| 137 | ): Shipment | null { |
| 138 | const { deployment, deploymentStatus } = data; |
| 139 | if (deploymentStatus?.state !== "success" || !deployment?.production_environment || !deployment.sha) return null; |
| 140 | const own = `/${repo.namespace}/${repo.name}`; |
| 141 | const url = |
| 142 | deployment.source === "g1t_page" && deployment.project |
| 143 | ? `/${repo.namespace}/${deployment.project}/deployments/${deployment.id}` |
| 144 | : (sitePath(deployment.run_url, site) ?? `${own}/deployments`); |
| 145 | return { |
| 146 | deploymentId: deployment.id, |
| 147 | repoId: data.repoId, |
| 148 | repo, |
| 149 | sha: deployment.sha, |
| 150 | environment: deployment.environment, |
| 151 | deployedAt: deploymentStatus.created_at || deployment.updated_at, |
| 152 | url, |
| 153 | }; |
| 154 | } |
| 155 | |
| 156 | type Env = { DB: D1Database; SITE: string }; |
| 157 | |
| 158 | /** |
| 159 | * Only types are imported here, so the rules above are tested on their |
| 160 | * own; the service hands in how to read history and as whom. |
| 161 | */ |
| 162 | export class Shipped { |
| 163 | constructor( |
| 164 | private readonly env: Env, |
| 165 | /** Reads a workspace's repositories as the workspace itself. */ |
| 166 | private readonly actorFor: (workspace: string) => Promise<User | null>, |
| 167 | private readonly readLog: ReadLog, |
| 168 | ) {} |
| 169 | |
| 170 | private get db(): D1Database { |
| 171 | return this.env.DB; |
| 172 | } |
| 173 | |
| 174 | /** Writes down what a production deployment shipped. Once per deployment. */ |
| 175 | async record(shipment: Shipment): Promise<number> { |
| 176 | const walked = await this.db |
| 177 | .prepare( |
| 178 | `INSERT INTO shipped_walks (deployment_id, repo_id, sha, environment, deployed_at) VALUES (?, ?, ?, ?, ?) |
| 179 | ON CONFLICT DO NOTHING RETURNING deployment_id`, |
| 180 | ) |
| 181 | .bind(shipment.deploymentId, shipment.repoId, shipment.sha, shipment.environment, shipment.deployedAt) |
| 182 | .first<{ deployment_id: string }>(); |
| 183 | if (!walked) return 0; |
| 184 | const actor = await this.actorFor(shipment.repo.namespace); |
| 185 | if (!actor) return 0; |
| 186 | let ref: string | null = shipment.sha; |
| 187 | let shipped = 0; |
| 188 | try { |
| 189 | for (let page = 0; page < WALK_PAGES && ref; page++) { |
| 190 | const history = await this.readLog(shipment.repo, actor, ref, WALK_PAGE); |
| 191 | if (!history.ok || history.value.length === 0) break; |
| 192 | const hashes = history.value.map((commit) => commit.hash); |
| 193 | const known = await this.db |
| 194 | .prepare("SELECT sha FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))") |
| 195 | .bind(shipment.repoId, JSON.stringify(hashes)) |
| 196 | .all<{ sha: string }>(); |
| 197 | const { ship, more, next } = walkPage(history.value, new Set(known.results.map((row) => row.sha)), history.value.length >= WALK_PAGE); |
| 198 | if (ship.length > 0) { |
| 199 | await this.db.batch( |
| 200 | ship.map((sha) => |
| 201 | this.db |
| 202 | .prepare( |
| 203 | `INSERT INTO shipped_commits (repo_id, sha, deployment_id, environment, deployed_at, url) VALUES (?, ?, ?, ?, ?, ?) |
| 204 | ON CONFLICT DO NOTHING`, |
| 205 | ) |
| 206 | .bind(shipment.repoId, sha, shipment.deploymentId, shipment.environment, shipment.deployedAt, shipment.url), |
| 207 | ), |
| 208 | ); |
| 209 | shipped += ship.length; |
| 210 | } |
| 211 | ref = more ? next : null; |
| 212 | } |
| 213 | } finally { |
| 214 | await this.db.prepare("UPDATE shipped_walks SET commits = ? WHERE deployment_id = ?").bind(shipped, shipment.deploymentId).run(); |
| 215 | } |
| 216 | return shipped; |
| 217 | } |
| 218 | |
| 219 | /** Whether each change asked about is live (see the module's notes). Members of the workspace only. */ |
| 220 | async answer(a: { workspace: string; viewer: Viewer; repos: { repo_id: string; commits: ShippedAsk[] }[] }): Promise<Result<ShippedRepo[]>> { |
| 221 | const workspace = String(a.workspace ?? "").toLowerCase(); |
| 222 | if (!a.viewer?.workspaces?.some((membership) => membership.slug === workspace)) { |
| 223 | return { ok: false, error: { code: "forbidden", message: "Only members can see a workspace's deployments." } }; |
| 224 | } |
| 225 | const asked = (Array.isArray(a.repos) ? a.repos : []).slice(0, MAX_REPOS); |
| 226 | let budget = MAX_COMMITS; |
| 227 | const out: ShippedRepo[] = []; |
| 228 | for (const repo of asked) { |
| 229 | const commits = (Array.isArray(repo.commits) ? repo.commits : []) |
| 230 | .filter((ask) => typeof ask?.sha === "string" && typeof ask?.landed_at === "string") |
| 231 | .slice(0, Math.max(0, budget)); |
| 232 | budget -= commits.length; |
| 233 | out.push(await this.forRepo(String(repo.repo_id), workspace, commits)); |
| 234 | } |
| 235 | return { ok: true, value: out }; |
| 236 | } |
| 237 | |
| 238 | private async forRepo(repoId: string, workspace: string, commits: ShippedAsk[]): Promise<ShippedRepo> { |
| 239 | // Only this workspace's repositories: the g1t.page builds and the |
| 240 | // reported deployments say whose they are. |
| 241 | const oldest = commits.reduce<string | null>((min, ask) => (min == null || ask.landed_at < min ? ask.landed_at : min), null); |
| 242 | const [shipped, start, builds, reported] = await Promise.all([ |
| 243 | commits.length |
| 244 | ? this.db |
| 245 | .prepare( |
| 246 | "SELECT sha, deployed_at, url, environment FROM shipped_commits WHERE repo_id = ? AND sha IN (SELECT value FROM json_each(?))", |
| 247 | ) |
| 248 | .bind(repoId, JSON.stringify(commits.map((ask) => ask.sha))) |
| 249 | .all<{ sha: string; deployed_at: string; url: string | null; environment: string }>() |
| 250 | : null, |
| 251 | this.db.prepare("SELECT MIN(deployed_at) AS at FROM shipped_walks WHERE repo_id = ?").bind(repoId).first<{ at: string | null }>(), |
| 252 | this.db |
| 253 | .prepare( |
| 254 | `SELECT id, slug, status, created_at, finished_at FROM deployments |
| 255 | WHERE repo_id = ? AND workspace = ? AND kind = 'production' AND created_at >= ? ORDER BY created_at LIMIT 200`, |
| 256 | ) |
| 257 | .bind(repoId, workspace, oldest ?? "9999") |
| 258 | .all<{ id: string; slug: string; status: string; created_at: string; finished_at: string | null }>(), |
| 259 | this.db |
| 260 | .prepare( |
| 261 | `SELECT id, environment, state, run_url, created_at, updated_at FROM reported_deployments |
| 262 | WHERE repo_id = ? AND production_environment = 1 AND created_at >= ? ORDER BY created_at LIMIT 200`, |
| 263 | ) |
| 264 | .bind(repoId, oldest ?? "9999") |
| 265 | .all<{ id: string; environment: string; state: string; run_url: string | null; created_at: string; updated_at: string }>(), |
| 266 | ]); |
| 267 | const deploys = |
| 268 | (builds.results.length > 0 || reported.results.length > 0 || (start?.at ?? null) != null) || |
| 269 | (await this.deploysAtAll(repoId, workspace)); |
| 270 | const attempts: Attempt[] = [ |
| 271 | ...builds.results.flatMap((row) => { |
| 272 | const outcome = buildOutcome(row.status); |
| 273 | return outcome |
| 274 | ? [{ at: outcome === "running" ? row.created_at : (row.finished_at ?? row.created_at), outcome, url: `/${workspace}/${row.slug}/deployments/${row.id}`, environment: "production" }] |
| 275 | : []; |
| 276 | }), |
| 277 | ...reported.results.flatMap((row) => { |
| 278 | const outcome = reportedOutcome(row.state); |
| 279 | // A success replaced since (`inactive`) was last changed when it was replaced: it went live nearer its start. |
| 280 | const at = outcome === "running" || row.state === "inactive" ? row.created_at : row.updated_at; |
| 281 | return outcome ? [{ at, outcome, url: sitePath(row.run_url, this.env.SITE), environment: row.environment }] : []; |
| 282 | }), |
| 283 | ].sort((x, y) => x.at.localeCompare(y.at)); |
| 284 | const byHash = new Map((shipped?.results ?? []).map((row) => [row.sha, row])); |
| 285 | return { |
| 286 | repo_id: repoId, |
| 287 | deploys, |
| 288 | commits: deploys ? commits.map((ask) => shipStateOf(ask, byHash.get(ask.sha) ?? null, attempts, start?.at ?? null)) : [], |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | /** Whether the repository ever deployed to production. */ |
| 293 | private async deploysAtAll(repoId: string, workspace: string): Promise<boolean> { |
| 294 | const [build, reported] = await Promise.all([ |
| 295 | this.db.prepare("SELECT 1 AS x FROM deployments WHERE repo_id = ? AND workspace = ? AND kind = 'production' LIMIT 1").bind(repoId, workspace).first(), |
| 296 | this.db.prepare("SELECT 1 AS x FROM reported_deployments WHERE repo_id = ? AND production_environment = 1 LIMIT 1").bind(repoId).first(), |
| 297 | ]); |
| 298 | return build != null || reported != null; |
| 299 | } |
| 300 | |
| 301 | /** A repository purged: what it shipped goes with it. */ |
| 302 | async purge(repoId: string): Promise<void> { |
| 303 | await this.db.batch([ |
| 304 | this.db.prepare("DELETE FROM shipped_commits WHERE repo_id = ?").bind(repoId), |
| 305 | this.db.prepare("DELETE FROM shipped_walks WHERE repo_id = ?").bind(repoId), |
| 306 | ]); |
| 307 | } |
| 308 | } |