import { WorkflowEntrypoint } from "cloudflare:workers"; import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers"; import { repoDb } from "../scoped-db"; import { siteDb } from "./store"; import { loadRepoById } from "../repos"; import { SCRATCH_LIMITS, STATIC_LIMITS } from "./config"; import { DeployError, extractArtifact, planGitFiles, totalBytes, uploadGitBatch } from "./collect"; import type { CollectedFiles, PlannedFile } from "./collect"; import { activateSiteDeployment, failSiteDeployment, loadSiteById, loadSiteDeployment } from "./deploy"; import { planBlockReason } from "../metering/quota"; export type SitesDeployParams = { deploymentId: string }; type Plan = | { kind: "branch"; artifactsName: string; sha: string; folder: string; maxFiles: number; maxBytes: number } | { kind: "artifact"; r2Key: string; folder: string } | { kind: "skip"; error: string | null }; type Outcome = { ok: true; value: T } | { ok: false; error: string }; async function attempt(work: () => Promise): Promise> { try { return { ok: true, value: await work() }; } catch (error) { if (error instanceof DeployError) return { ok: false, error: error.message }; throw error; } } async function start(deploymentId: string): Promise { const deployment = await loadSiteDeployment(deploymentId); if (!deployment || deployment.status !== "queued") return { kind: "skip", error: null }; const site = await loadSiteById(deployment.site_id); const repo = site?.repo_id ? await loadRepoById(site.repo_id) : null; if (!site || !repo) return { kind: "skip", error: "The site is no longer linked to a repository" }; if (site.owner_id && site.scratch !== 1) { const blocked = await planBlockReason(site.owner_id, "storage.sites"); if (blocked) return { kind: "skip", error: blocked }; } await siteDb(site).run("UPDATE site_deployments SET status = 'building' WHERE id = ?", deploymentId); if (!deployment.artifact_id) { const scratch = site.scratch === 1; return { kind: "branch", artifactsName: repo.artifacts_name, sha: deployment.sha, folder: site.folder, maxFiles: scratch ? SCRATCH_LIMITS.maxFiles : STATIC_LIMITS.maxFiles, maxBytes: scratch ? SCRATCH_LIMITS.maxTotalBytes : STATIC_LIMITS.maxTotalBytes, }; } const artifact = await repoDb(repo.id).first<{ r2_key: string }>( "SELECT a.r2_key FROM job_artifacts a JOIN workflow_runs r ON r.id = a.run_id WHERE a.id = ? AND r.repo_id = ?", deployment.artifact_id, repo.id, ); if (!artifact) return { kind: "skip", error: "Workflow artifact not found" }; return { kind: "artifact", r2Key: artifact.r2_key, folder: site.folder }; } /** Durable pipeline: content-addressed upload, then manifest and label pointer. */ export class SitesDeployWorkflow extends WorkflowEntrypoint { override async run(event: Readonly>, step: WorkflowStep): Promise { try { await this.deploy(event.payload.deploymentId, step); } catch (error) { // Steps already retried; record the failure so the row doesn't stay "building" and hold its label. await step.do("fail-unexpected", () => failSiteDeployment(event.payload.deploymentId, "Deploy failed unexpectedly"), ); throw error; } } private async deploy(deploymentId: string, step: WorkflowStep): Promise { const fail = (message: string) => step.do("fail", () => failSiteDeployment(deploymentId, message)); const plan = await step.do("start", () => start(deploymentId)); if (plan.kind === "skip") { if (plan.error) await fail(plan.error); return; } let files: CollectedFiles = {}; if (plan.kind === "artifact") { const extracted = await step.do("extract", () => attempt(() => extractArtifact(plan.r2Key, plan.folder))); if (!extracted.ok) return void (await fail(extracted.error)); files = extracted.value; } else { const planned = await step.do("plan", () => attempt(() => planGitFiles(plan.artifactsName, plan.sha, plan.folder, plan.maxFiles)), ); if (!planned.ok) return void (await fail(planned.error)); const list: PlannedFile[] = planned.value; for (let index = 0; index < list.length; index += STATIC_LIMITS.uploadBatch) { const batch = list.slice(index, index + STATIC_LIMITS.uploadBatch); const uploaded = await step.do(`upload-${index}`, () => attempt(() => uploadGitBatch(plan.artifactsName, batch)), ); if (!uploaded.ok) return void (await fail(uploaded.error)); files = { ...files, ...uploaded.value }; if (totalBytes(files) > plan.maxBytes) return void (await fail("Site exceeds the size limit")); } } const collected = files; await step.do("activate", () => activateSiteDeployment(deploymentId, collected)); } }