import { env } from "cloudflare:workers"; import { all, batch, chunks, first, placeholders, run, stmt } from "../db"; import { HttpError } from "../errors"; import { loadRepoById } from "../repos"; import { customDomainsEnabled, deleteCustomHostname } from "../pages/domains"; import { customHostKey, labelUrl, metaKey, publishMeta, sitesDomain, slotKey } from "./config"; import { assertLabelFree, loadSiteById, loadSiteForRepo } from "./deploy"; import { siteLabel } from "./naming"; import { refreshSiteDomain } from "./site"; import { unpublish as unpublishPages } from "../pages/deploy"; import type { SiteMeta, SiteRow } from "./config"; const OWNER_BATCH = 200; const RELABEL_BLOCKED = "Site host"; const RECONCILE_BATCH = 100; const DOMAIN_BATCH = 10; const META_PAGE = 500; const CURSOR_KEY = "sites:reconcile:cursor"; const META_CURSOR_KEY = "sites:reconcile:meta-cursor"; /** Unpublishes everything a site serves (meta, every label, custom host, SaaS hostname) from KV-only state. */ async function teardown( meta: Pick, labels: string[], ): Promise { await Promise.all(labels.map((label) => env.KV.delete(slotKey(label)))); if (meta.customDomain) await env.KV.delete(customHostKey(meta.customDomain)); if (meta.customHostnameId && customDomainsEnabled()) { await deleteCustomHostname(meta.customHostnameId).catch((error: unknown) => console.warn("Custom hostname cleanup failed", meta.id, error), ); } await env.KV.delete(metaKey(meta.id)); } async function servedLabels(site: SiteRow): Promise { const rows = await all<{ label: string }>( "SELECT DISTINCT label FROM site_deployments WHERE site_id = ? AND status = 'active' LIMIT 500", site.id, ); return [site.label, ...rows.map((row) => row.label)]; } /** * Captures what a repo's site serves before the repository row is deleted (D1 cascades the site away) and returns * the teardown to run once the delete has committed. */ export async function siteTeardownFor(repoId: string): Promise<() => Promise> { const site = await loadSiteForRepo(repoId); if (!site) return async () => {}; const labels = await servedLabels(site); const meta = { id: site.id, customDomain: site.custom_domain, customHostnameId: site.custom_hostname_id }; return () => teardown(meta, labels); } /** Moves a site to the label its current owner and name imply; previews are dropped and rebuild on the next push. */ async function relabel(site: SiteRow, label: string): Promise { const labels = await servedLabels(site); await batch([ stmt( "UPDATE sites SET label = ?, disabled_reason = CASE WHEN disabled_reason LIKE ? THEN NULL ELSE disabled_reason END WHERE id = ?", label, `${RELABEL_BLOCKED}%`, site.id, ), stmt("UPDATE site_deployments SET label = ? WHERE site_id = ? AND label = ?", label, site.id, site.label), stmt( "UPDATE site_deployments SET status = 'superseded' WHERE site_id = ? AND environment = 'preview' AND status = 'active'", site.id, ), ]); await Promise.all(labels.map((old) => env.KV.delete(slotKey(old)))); if (site.active_deployment_id) { await env.KV.put(slotKey(label), JSON.stringify({ siteId: site.id, deploymentId: site.active_deployment_id })); } if (site.custom_domain) { const custom = await env.KV.get(customHostKey(site.custom_domain)); if (custom) await env.KV.put(customHostKey(site.custom_domain), JSON.stringify({ siteId: site.id, label })); } return (await loadSiteById(site.id)) ?? site; } /** * Re-derives a repo site's state from its repository: tears it down when the repo is gone, follows renames and * transfers, and gates a public site whose repository became private. */ export async function syncSiteForRepo(repoId: string): Promise { let site = await loadSiteForRepo(repoId); if (!site) return; const repo = await loadRepoById(repoId); if (!repo) { await ( await siteTeardownFor(repoId) )(); return; } const expected = siteLabel(repo.owner_name, repo.name, sitesDomain()); if (expected !== site.label) { const taken = await assertLabelFree(site.id, expected).then( () => false, (error: unknown) => { if (error instanceof HttpError) return true; throw error; }, ); if (taken) { console.warn("Site relabel blocked", site.id, labelUrl(expected)); await run( "UPDATE sites SET disabled_reason = ? WHERE id = ? AND disabled_reason IS NULL", `${RELABEL_BLOCKED} ${expected} is taken`, site.id, ); site = (await loadSiteById(site.id)) ?? site; } else { site = await relabel(site, expected); } } if (repo.visibility !== "public" && site.access === "public" && site.scratch !== 1) { await run( "UPDATE sites SET access = 'gitfor', access_version = access_version + 1, updated_at = ? WHERE id = ?", Date.now(), site.id, ); site = (await loadSiteById(site.id)) ?? site; } await publishMeta(site); } /** Pages only publishes public repositories; a repository that went private stops serving until redeployed. */ async function syncPagesForRepo(repoId: string): Promise { const repo = await loadRepoById(repoId); if (!repo || repo.visibility === "public") return; const pages = await first<{ active_deployment_id: string | null }>( "SELECT active_deployment_id FROM pages_sites WHERE repo_id = ?", repoId, ); const live = await first<{ id: string }>( "SELECT id FROM pages_deployments WHERE repo_id = ? AND status IN ('active', 'queued', 'building') LIMIT 1", repoId, ); if (!pages || (!pages.active_deployment_id && !live)) return; await unpublishPages(repo); // Nothing stays "active", so making the repository public again serves nothing until a new deploy or rollback. await run("UPDATE pages_sites SET active_deployment_id = NULL, updated_at = ? WHERE repo_id = ?", Date.now(), repoId); await run("UPDATE pages_deployments SET status = 'superseded' WHERE repo_id = ? AND status = 'active'", repoId); await run( "UPDATE pages_deployments SET status = 'failed', error = ?, completed_at = ? WHERE repo_id = ? AND status IN ('queued', 'building')", "Cancelled: the repository became private", Date.now(), repoId, ); } /** After any visibility, name or owner change: brings both Sites and Pages in line with the repository. */ export async function syncHostingForRepo(repoId: string): Promise { await syncSiteForRepo(repoId); await syncPagesForRepo(repoId); } export async function syncSitesForOwner(ownerId: string): Promise { const rows = await all<{ repo_id: string }>( "SELECT s.repo_id FROM sites s JOIN repos r ON r.id = s.repo_id WHERE r.owner_id = ? LIMIT ?", ownerId, OWNER_BATCH, ); for (const row of rows) await syncSiteForRepo(row.repo_id); } async function reconcileRepoSites(): Promise { const cursor = (await env.KV.get(CURSOR_KEY)) ?? ""; const rows = await all<{ id: string; repo_id: string }>( "SELECT id, repo_id FROM sites WHERE repo_id IS NOT NULL AND id > ? ORDER BY id LIMIT ?", cursor, RECONCILE_BATCH, ); for (const row of rows) await syncSiteForRepo(row.repo_id); const privatePages = await all<{ repo_id: string }>( `SELECT p.repo_id FROM pages_sites p JOIN repos r ON r.id = p.repo_id WHERE r.visibility != 'public' AND p.active_deployment_id IS NOT NULL LIMIT ?`, DOMAIN_BATCH, ); for (const row of privatePages) await syncPagesForRepo(row.repo_id); await env.KV.put(CURSOR_KEY, rows.length === RECONCILE_BATCH ? rows[rows.length - 1].id : ""); const domains = await all( "SELECT * FROM sites WHERE custom_hostname_id IS NOT NULL ORDER BY COALESCE(domain_checked_at, 0) LIMIT ?", DOMAIN_BATCH, ); for (const site of domains) { await refreshSiteDomain(site).catch((error: unknown) => console.warn("Domain refresh failed", site.id, error)); await run("UPDATE sites SET domain_checked_at = ? WHERE id = ?", Date.now(), site.id); } } /** Deletes KV metadata whose D1 site row is gone (for example after a cascade), so the host stops serving. */ async function reconcileOrphanMeta(): Promise { const cursor = (await env.KV.get(META_CURSOR_KEY)) ?? undefined; const page = await env.KV.list({ prefix: "sites:meta:", cursor, limit: META_PAGE }); const ids = page.keys.map((key) => key.name.slice("sites:meta:".length)); for (const group of chunks(ids)) { const live = await all<{ id: string }>( `SELECT id FROM sites WHERE id IN (${placeholders(group.length)})`, ...group, ); const known = new Set(live.map((row) => row.id)); for (const id of group) { if (known.has(id)) continue; const meta = await env.KV.get(metaKey(id), "json"); if (meta) await teardown(meta, [meta.label]); } } if (page.list_complete) await env.KV.delete(META_CURSOR_KEY); else await env.KV.put(META_CURSOR_KEY, page.cursor); } export async function reconcileSites(): Promise { if (!sitesDomain()) return; await reconcileRepoSites(); await reconcileOrphanMeta(); } /** True when the slot's owner no longer exists, so a new site may take the label over. */ export async function isOrphanSite(siteId: string): Promise { return (await first<{ id: string }>("SELECT id FROM sites WHERE id = ?", siteId)) === null; }