import { backfillDone, backfillOnce } from "../backfill"; import { all } from "../db"; import { repoDb, type ScopedDb } from "../scoped-db"; import { forgetLocator, locate, locatedByPrefix, locatedScopes, locatedScopesAmong, locatedScopesFrom, registerLocator, } from "../scoped-db/locators"; import type { SiteRow } from "./config"; import { forgetSiteDirectory } from "./directory"; /** * Where a site's rows live. A repository's site sits in that repository's RepoState; a repo-less site (an upload * scratch site, claimed or not) is its own scope, `site:`, in a RepoState of its own. */ export function siteScope(site: Pick): string { return site.repo_id ?? `site:${site.id}`; } export function siteDb(site: Pick): ScopedDb { return repoDb(siteScope(site)); } /** The store holding `siteId`, from its `site` locator; null when the site is unknown. */ export async function siteDbById(siteId: string): Promise { const scope = await locate("site", siteId); return scope ? repoDb(scope) : null; } export async function loadSiteRow(siteId: string): Promise { const db = await siteDbById(siteId); return db ? db.first("SELECT * FROM sites WHERE id = ?", siteId) : null; } /** The store holding a deployment, from its `site_deployment` locator. */ export async function deploymentDb(deploymentId: string): Promise { const scope = await locate("site_deployment", deploymentId); return scope ? repoDb(scope) : null; } export function hostKey(host: string): string { return host.toLowerCase(); } /** Registers the site so it can be found by id and by its production label. */ export async function registerSite(site: Pick): Promise { await registerLocator("site", site.id, siteScope(site)); await claimHost(site.label, site.id); } /** Hosts (labels and custom domains) map to the site id that holds them. */ export async function claimHost(host: string, siteId: string): Promise { await registerLocator("site_host", hostKey(host), siteId); } /** Releases a host only while `siteId` still holds it, so a newer holder keeps its claim. */ export async function releaseHost(host: string, siteId: string): Promise { if ((await locate("site_host", hostKey(host))) === siteId) await forgetLocator("site_host", hostKey(host)); } export async function hostHolder(host: string): Promise { return locate("site_host", hostKey(host)); } /** Every scope (repository or `site:`) that holds a site, for platform-wide sweeps. */ export async function siteScopes(): Promise { return locatedScopes("site"); } /** A page of site scopes in id order, starting at `from` (inclusive). */ export async function siteScopesFrom(from: string, limit: number): Promise { return locatedScopesFrom("site", from, limit); } /** Every site row, walking the site scopes. Data is small; bounded by `limit` when given. */ export async function allSites(where = "1 = 1", ...params: Array): Promise { const out: SiteRow[] = []; for (const scope of await siteScopes()) { out.push(...(await repoDb(scope).all(`SELECT * FROM sites WHERE ${where}`, ...params))); } return out; } const DOMAIN_SITE_INDEX = "site-domain-locators"; /** Indexes a site about to take a custom domain; registered before the row so a crash only leaves a stale entry. */ export async function registerDomainSite(site: Pick): Promise { await registerLocator("site_domain", site.id, siteScope(site)); } export async function forgetDomainSite(siteId: string): Promise { await forgetLocator("site_domain", siteId); } /** Cron step until done: indexes sites that held a custom domain before `site_domain` locators existed. */ export async function backfillDomainSites(): Promise { await backfillOnce(DOMAIN_SITE_INDEX, async () => { for (const site of await allSites("custom_domain IS NOT NULL")) await registerDomainSite(site); }); } /** Sites holding a custom domain (and matching `where`), visiting only the scopes indexed as holding one. */ export async function domainSites(where = "1 = 1", ...params: Array): Promise { const filter = `custom_domain IS NOT NULL AND (${where})`; if (!(await backfillDone(DOMAIN_SITE_INDEX))) return allSites(filter, ...params); const out: SiteRow[] = []; for (const scope of await locatedScopes("site_domain")) { out.push(...(await repoDb(scope).all(`SELECT * FROM sites WHERE ${filter}`, ...params))); } return out; } const OWNER_SITE_INDEX = "owner-site-locators"; function ownerSiteKey(ownerId: string, siteId: string): string { return `${ownerId}:${siteId}`; } /** Indexes a repo-less site under the account that claimed it; repository sites follow `repos.owner_id`. */ export async function registerOwnedSite(ownerId: string, siteId: string): Promise { await registerLocator("owner_site", ownerSiteKey(ownerId, siteId), `site:${siteId}`); } /** Cron step until done: indexes repo-less sites claimed before `owner_site` locators existed. */ export async function backfillOwnedSites(): Promise { await backfillOnce(OWNER_SITE_INDEX, async () => { for (const scope of await siteScopes()) { if (!scope.startsWith("site:")) continue; const rows = await repoDb(scope).all<{ id: string; owner_id: string }>( "SELECT id, owner_id FROM sites WHERE owner_id IS NOT NULL AND repo_id IS NULL", ); for (const row of rows) await registerOwnedSite(row.owner_id, row.id); } }); } /** * Scopes that may hold sites (or site usage) of `ownerId`: site scopes of repositories it owns plus the repo-less * sites it claimed. Callers still filter by owner or account id. */ export async function ownerSiteScopes(ownerId: string): Promise { const [repos, indexed] = await Promise.all([ all<{ id: string }>("SELECT id FROM repos WHERE owner_id = ?", ownerId), backfillDone(OWNER_SITE_INDEX), ]); const [repoScopes, claimed] = await Promise.all([ locatedScopesAmong( "site", repos.map((repo) => repo.id), ), indexed ? locatedByPrefix("owner_site", `${ownerId}:`) : siteScopes().then((scopes) => scopes.filter((scope) => scope.startsWith("site:"))), ]); return [...new Set([...repoScopes, ...claimed])]; } /** Sites billed to `ownerId`: those in repositories it owns plus repo-less sites it claimed. */ export async function sitesOwnedBy(ownerId: string): Promise { const out: SiteRow[] = []; for (const scope of await ownerSiteScopes(ownerId)) { out.push(...(await repoDb(scope).all("SELECT * FROM sites WHERE owner_id = ?", ownerId))); } return out; } /** Drops every locator of a deleted site (its scope rows go with the repository or are deleted by the caller). */ export async function forgetSite( site: Pick, ): Promise { await releaseHost(site.label, site.id); if (site.custom_domain) await releaseHost(site.custom_domain, site.id); if (site.owner_id && !site.repo_id) await forgetLocator("owner_site", ownerSiteKey(site.owner_id, site.id)); await forgetDomainSite(site.id); await forgetSiteDirectory(site.id); await forgetLocator("site", site.id); }