// The publish stages' on-disk state (release 18): three stamp files, read by // every stage's `needs()` and written only by the stage that owns them. // // /stamp.json IndexStamp (update-index) // //built.json BuiltStamp (build-site/hub/homepage) // //deployed.json DeployedFile (deploy-*) // // `target` is a site id, "_hub" or "_homepage". Every write is atomic (temp + // rename, lib/jsonFile-server.ts); every read is TOLERANT: a missing, // unparseable or wrongly-shaped file is null, and null means "stale" to every // `needs()` — a stage never trusts half a stamp. // // The shapes are the plan's ("Model", plans/release-18.md) and other slices // code against them: S2's live check fills `DeployRecord.liveCheck` with the // `LiveCheck` / `Probe` types declared here, S3's status view reads all three. import { randomBytes } from "node:crypto"; import path from "node:path"; import { readJsonFile, writeJsonAtomic } from "../lib/jsonFile-server"; import type { Paths } from "../lib/paths"; export const HUB_TARGET = "_hub"; export const HOMEPAGE_TARGET = "_homepage"; export const INDEX_TARGET = "_index"; export const ALL_TARGET = "_all"; export type IndexStamp = { v: 1; stampId: string; // LMDB meta `generation` after the build (buildIndex.ts). generation: number; // LMDB meta INDEX_SCANNED_AT_KEY: when the completed scan began (ms). scannedAt: number; // When the stage finished (ms). builtAt: number; // When `build templates` finished (ms). templatesAt: number; commit: string | null; index: { shortCircuited: boolean; added: number; changed: number; removed: number; heldChannels: string[]; }; stats: { shortCircuited: boolean; notIndexedYet: number; notIndexable: number }; // Per site: sha1 of the LMDB `siteFp:` / `statsFp:` fingerprints // (null when absent), and the inputSig compose's skip rule is computed from. sites: Record; hubSig: string; // inputSig.ts indexSettingsSig over the settings the index read (absent on // a stamp written before release 18's surfaces: it reads as changed). settingsSig?: string; }; export type BuiltKind = "site" | "hub" | "homepage"; export type Runner = "local" | "docker"; export type BuiltStamp = { v: 1; stampId: string; target: string; kind: BuiltKind; // The IndexStamp the bundle was built from (null: none on disk then). indexStampId: string | null; inputSig: string; builtAt: number; // When a later no-op build last found this bundle still matching its inputs // (absent: never). `changedChannels` is measured against max(builtAt, it). checkedAt?: number; commit: string | null; branch: string | null; runner: Runner; audience: "public" | "private"; // The bundle's corpus.json `generatedAt` (what the live check compares). corpusGeneratedAt: string | null; files: number; bytes: number; // Oversize archives staged for R2 beside the bundle. archivesStaged: number; // The homepage only: the commit its published source was cut from. sourceCommit?: string | null; }; export type Probe = { status: number | null; generatedAt?: string; cfCacheStatus?: string; age?: number; cacheControl?: string; error?: string; }; export type LiveCheck = { at: number; url: string; plain: Probe; busted: Probe; expected: string | null; verdict: "ok" | "stale-edge" | "mismatch" | "unreachable" | "skipped"; tombstones?: { path: string; plain: Probe; busted: Probe; ok: boolean }[]; }; export type DeployKind = "production" | "preview" | "local"; export type DeployRecord = { builtStampId: string; builtAt: number; kind: DeployKind; branch?: string; url: string | null; alias?: string; at: number; wrangler?: string; liveCheck: LiveCheck | null; }; export type DeployedFile = { v: 1; target: string; production?: DeployRecord; local?: DeployRecord; previews: Record; }; // --- paths -------------------------------------------------------------------- export function indexStampPath(paths: Pick): string { return path.join(paths.exportIndexDir, "stamp.json"); } export function targetDir(paths: Pick, target: string): string { return path.join(paths.exportBuildsDir, target); } export function builtStampPath(paths: Pick, target: string): string { return path.join(targetDir(paths, target), "built.json"); } export function deployedPath(paths: Pick, target: string): string { return path.join(targetDir(paths, target), "deployed.json"); } // The runtime image's build facts (Dockerfile build args → ENV): the stamps' // `commit` / `branch` where there is no checkout to ask. Declared once, beside // the two names, in lib/envVars.ts (release 18 S5); re-exported for the stages. export { imageBuildFacts } from "../lib/envVars"; /** A fresh, sortable, unique stamp id. */ export function newStampId(now = Date.now()): string { return `${now.toString(36).padStart(9, "0")}-${randomBytes(4).toString("hex")}`; } // --- shape checks (pure; exported for the tests) ------------------------------- type Obj = Record; const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v); const isStr = (v: unknown): v is string => typeof v === "string"; const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); const isStrOrNull = (v: unknown) => v === null || isStr(v); const isBool = (v: unknown): v is boolean => typeof v === "boolean"; export function asIndexStamp(v: unknown): IndexStamp | null { if (!isObj(v) || v.v !== 1) return null; if (!isStr(v.stampId) || !isNum(v.generation) || !isNum(v.scannedAt)) return null; if (!isNum(v.builtAt) || !isNum(v.templatesAt) || !isStrOrNull(v.commit)) return null; const ix = v.index; if (!isObj(ix) || !isBool(ix.shortCircuited) || !isNum(ix.added) || !isNum(ix.changed)) return null; if (!isNum(ix.removed) || !Array.isArray(ix.heldChannels) || !ix.heldChannels.every(isStr)) return null; const st = v.stats; if (!isObj(st) || !isBool(st.shortCircuited) || !isNum(st.notIndexedYet) || !isNum(st.notIndexable)) { return null; } if (!isObj(v.sites) || !isStr(v.hubSig)) return null; if (v.settingsSig !== undefined && !isStr(v.settingsSig)) return null; for (const s of Object.values(v.sites)) { if (!isObj(s) || !isStr(s.inputSig) || !isStrOrNull(s.siteFp) || !isStrOrNull(s.statsFp)) return null; } return v as unknown as IndexStamp; } export function asBuiltStamp(v: unknown): BuiltStamp | null { if (!isObj(v) || v.v !== 1) return null; if (!isStr(v.stampId) || !isStr(v.target) || !isStr(v.inputSig) || !isNum(v.builtAt)) return null; if (v.kind !== "site" && v.kind !== "hub" && v.kind !== "homepage") return null; if (v.runner !== "local" && v.runner !== "docker") return null; if (v.audience !== "public" && v.audience !== "private") return null; if (!isStrOrNull(v.indexStampId) || !isStrOrNull(v.commit) || !isStrOrNull(v.branch)) return null; if (!isStrOrNull(v.corpusGeneratedAt)) return null; if (!isNum(v.files) || !isNum(v.bytes) || !isNum(v.archivesStaged)) return null; if (v.sourceCommit !== undefined && !isStrOrNull(v.sourceCommit)) return null; if (v.checkedAt !== undefined && !isNum(v.checkedAt)) return null; return v as unknown as BuiltStamp; } function asDeployRecord(v: unknown): DeployRecord | null { if (!isObj(v)) return null; if (!isStr(v.builtStampId) || !isNum(v.builtAt) || !isNum(v.at)) return null; if (v.kind !== "production" && v.kind !== "preview" && v.kind !== "local") return null; if (!isStrOrNull(v.url)) return null; if (v.liveCheck !== null && !isObj(v.liveCheck)) return null; return v as unknown as DeployRecord; } export function asDeployedFile(v: unknown): DeployedFile | null { if (!isObj(v) || v.v !== 1 || !isStr(v.target) || !isObj(v.previews)) return null; for (const key of ["production", "local"] as const) { if (v[key] !== undefined && asDeployRecord(v[key]) === null) return null; } for (const r of Object.values(v.previews)) if (asDeployRecord(r) === null) return null; return v as unknown as DeployedFile; } // --- read / write ------------------------------------------------------------- async function readAs(file: string, as: (v: unknown) => T | null): Promise { const r = await readJsonFile(file); return r.ok ? as(r.value) : null; } export function readIndexStamp(paths: Pick): Promise { return readAs(indexStampPath(paths), asIndexStamp); } export function writeIndexStamp(paths: Pick, stamp: IndexStamp): Promise { return writeJsonAtomic(indexStampPath(paths), stamp, { mkdir: true }); } export function readBuiltStamp( paths: Pick, target: string, ): Promise { return readAs(builtStampPath(paths, target), asBuiltStamp); } export function writeBuiltStamp(paths: Pick, stamp: BuiltStamp): Promise { return writeJsonAtomic(builtStampPath(paths, stamp.target), stamp, { mkdir: true }); } export function readDeployedFile( paths: Pick, target: string, ): Promise { return readAs(deployedPath(paths, target), asDeployedFile); } /** The record a deploy of `kind` (and `branch`, for a preview) left, or null. */ export function deployRecordFor( file: DeployedFile | null, kind: DeployKind, branch?: string, ): DeployRecord | null { if (!file) return null; if (kind === "production") return file.production ?? null; if (kind === "local") return file.local ?? null; return branch ? (file.previews[branch] ?? null) : null; } /** * Record one deploy into `/deployed.json`, keeping every other record * (a malformed file is replaced). Returns the file written. */ export async function recordDeploy( paths: Pick, target: string, record: DeployRecord, ): Promise { const prev = (await readDeployedFile(paths, target)) ?? { v: 1 as const, target, previews: {} }; const next: DeployedFile = { ...prev, v: 1, target, previews: { ...prev.previews } }; if (record.kind === "production") next.production = record; else if (record.kind === "local") next.local = record; else if (record.branch) next.previews[record.branch] = record; await writeJsonAtomic(deployedPath(paths, target), next, { mkdir: true }); return next; }