1import { parseManifest } from "../../shared/mod.ts";2import type { Env } from "./env.ts";34// An R2 object is live iff it is referenced by the current manifest or any5// retained history entry of any non-deleted site. The 24h age guard means an6// in-flight push (blobs uploaded, commit pending) can never lose objects to a7// concurrent sweep.8const MIN_AGE_MS = 24 * 3600 * 1000;910export async function runGc(11 env: Env,12): Promise<{ expiredSites: number; deletedObjects: number }> {13 const now = Math.floor(Date.now() / 1000);1415 // 1. Sweep expired sites (cascades to pushes history).16 const expired = await env.DB.prepare(17 "DELETE FROM sites WHERE expires_at IS NOT NULL AND expires_at < ? RETURNING address",18 ).bind(now).all<{ address: number }>();1920 // 2. Mark: every manifest referenced by any remaining site or history row.21 const liveManifests = new Set<string>();22 const current = await env.DB.prepare("SELECT current_manifest FROM sites")23 .all<{ current_manifest: string }>();24 for (const r of current.results) liveManifests.add(r.current_manifest);25 const historic = await env.DB.prepare(26 "SELECT DISTINCT manifest_hash FROM pushes",27 )28 .all<{ manifest_hash: string }>();29 for (const r of historic.results) liveManifests.add(r.manifest_hash);3031 // Live blobs = union of files across live manifests.32 const liveBlobs = new Set<string>();33 for (const hash of liveManifests) {34 const obj = await env.CONTENT.get(`manifest/${hash}`);35 if (!obj) continue;36 const manifest = parseManifest(new Uint8Array(await obj.arrayBuffer()));37 for (const f of Object.values(manifest.files)) liveBlobs.add(f.h);38 }3940 // 3. Sweep R2: delete unreferenced objects older than the age guard.41 let deleted = 0;42 const cutoff = Date.now() - MIN_AGE_MS;43 for (const prefix of ["manifest/", "blob/"] as const) {44 let cursor: string | undefined;45 do {46 const page: R2Objects = await env.CONTENT.list({47 prefix,48 cursor,49 limit: 1000,50 });51 const doomed = page.objects.filter((o) => {52 if (o.uploaded.getTime() > cutoff) return false;53 const hash = o.key.slice(prefix.length);54 return prefix === "manifest/"55 ? !liveManifests.has(hash)56 : !liveBlobs.has(hash);57 }).map((o) => o.key);58 if (doomed.length > 0) {59 await env.CONTENT.delete(doomed);60 deleted += doomed.length;61 }62 cursor = page.truncated ? page.cursor : undefined;63 } while (cursor);64 }6566 return { expiredSites: expired.results.length, deletedObjects: deleted };67}