#!/usr/bin/env tsx // Warm the shared, persistent archive cache (transcripts/export/archives) for the // UNION of every enabled site's member channels, in a single pass, on the host. // // This hoists archive-zip *generation* out of the per-site compose. Under the // docker build pipeline the per-site compose runs in parallel containers over a // read-only mount of this cache (ARCHIVES_READONLY=1) — so generation must happen // once, up front, or two sites sharing a cold channel would race to write the same // .zip. Running it here (Phase A, serial, before the fan-out) makes every // container's compose a pure materialize. // // It's signature-gated (see channelSignature.ts) exactly like the compose path, // so an unchanged channel is reused, not re-zipped — this is a no-op on a warm // cache and also speeds the basic (non-docker) build by collapsing N per-site // passes into one union pass. import { getPaths, type Paths } from "../lib/paths"; import { getSettings } from "../lib/settings"; import { listSites } from "../lib/site"; import { openChannelSigner } from "../lib/channelSignature"; import { archiveCacheDir, archiveTranscripts, } from "../controller/archiveTranscripts"; import { archiveLiveChat } from "../controller/archiveLiveChat"; import { runIfEntryPoint } from "./_cli"; export async function main(opts: { paths?: Paths } = {}): Promise { // Honor the same global/per-build opt-outs the per-site compose respects // (archivesEnabled in compose-site.ts). The per-site `archives` flag is applied // below when building the union. if (getSettings().buildArchives === false) { console.log("[archives] disabled globally (buildArchives=false) — nothing to warm."); return; } if (process.env.BUILD_ARCHIVES === "0") { console.log("[archives] BUILD_ARCHIVES=0 — skipping archive cache warm."); return; } const paths = opts.paths ?? getPaths(); const sites = listSites(paths); // Union of member slugs across sites that ship archives. A channel shared by // several sites is warmed once; the readonly per-site compose then materializes // its subset. const union = new Set(); for (const site of sites) { if (site.archives === false) continue; for (const c of site.channels) union.add(c.slug); } const channelSlugs = [...union].sort(); if (channelSlugs.length === 0) { console.log("[archives] no archive-enabled member channels — nothing to warm."); return; } console.log( `[archives] warming shared cache for ${channelSlugs.length} channel(s) across ${sites.length} site(s)…`, ); // Match the per-site compose's build options EXACTLY so the sidecar signatures // line up and the readonly compose sees cache hits (compose-site.ts uses // `{ format: "zip" }`, i.e. the resolved defaults). const build = { format: "zip" as const }; const channelConcurrency = Number(process.env.ARCHIVE_CHANNEL_CONCURRENCY) || 4; const log = (m: string) => { if (m) console.log(`[archives] ${m}`); }; const signer = openChannelSigner(paths); try { const common = { paths, channelSlugs, outDir: archiveCacheDir(paths), build, channelConcurrency, onLog: log, signer, }; await archiveTranscripts(common); await archiveLiveChat(common); } finally { await signer.close(); } console.log("[archives] cache warm complete."); } runIfEntryPoint(import.meta.url, () => main());