/** * Shared fast-deploy snapshot helpers for the CI scripts. * * Both `migrate-blocks-to-kv.ts` and `sync-blocks-to-kv.ts` write a snapshot * keyed by DEPLOYMENT ID (`decofile:` + `index:revision:`) so every code * deployment reads its own content. The revision is computed with the SAME * `computeRevision` the runtime uses (`src/cms/blockSource.ts`) so a hydrating * isolate computes a matching revision and the poller doesn't loop. */ import { computeRevision, DEPLOYMENTS_KEY, LIVE_KEY, revisionKey, snapshotKey, } from "@decocms/blocks/cms"; import type { KvRestClient } from "./cf-kv-rest"; export interface Snapshot { /** Serialized decofile written to `decofile:`. */ snapshot: string; /** DJB2 revision written to `index:revision:`. */ revision: string; /** Block count, for logging. */ count: number; } /** One entry in the `index:deployments` GC bookkeeping list. */ export interface DeploymentEntry { id: string; ts: number; } export function buildSnapshot(blocks: Record): Snapshot { return { snapshot: JSON.stringify(blocks), revision: computeRevision(blocks), count: Object.keys(blocks).length, }; } /** Write the snapshot + revision for deployment `id`. Snapshot first, then * revision, so a poller never sees a new revision pointing at an old snapshot. */ export async function writeSnapshotToKv( client: KvRestClient, snap: Snapshot, id: string, ): Promise { await client.put(snapshotKey(id), snap.snapshot); await client.put(revisionKey(id), snap.revision); } /** Read both keys for deployment `id` back and confirm the revision matches. */ export async function verifySnapshotInKv( client: KvRestClient, expectedRevision: string, id: string, ): Promise<{ ok: boolean; reason?: string }> { const [snapshot, revision] = await Promise.all([ client.get(snapshotKey(id)), client.get(revisionKey(id)), ]); if (snapshot === null) return { ok: false, reason: `${snapshotKey(id)} missing` }; if (revision !== expectedRevision) { return { ok: false, reason: `${revisionKey(id)} is "${revision}", expected "${expectedRevision}"`, }; } return { ok: true }; } /** Point `index:live` at deployment `id` (post-activation, from the deploy step). */ export async function setLiveDeployment(client: KvRestClient, id: string): Promise { await client.put(LIVE_KEY, id); } async function readDeployments(client: KvRestClient): Promise { const raw = await client.get(DEPLOYMENTS_KEY); if (!raw) return []; try { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) { return parsed.filter( (e): e is DeploymentEntry => !!e && typeof e === "object" && typeof (e as DeploymentEntry).id === "string", ); } } catch { // Corrupt bookkeeping key — start fresh rather than fail the sync. } return []; } /** * Record deployment `id` in `index:deployments` (newest last, deduped) and GC * snapshots beyond the last `retain`. The currently-live deployment * (`index:live`) is never pruned even if it falls outside the window (protects * a rollback to an older version). Returns the list of pruned ids. */ export async function recordAndGcDeployment( client: KvRestClient, id: string, ts: number, retain: number, ): Promise<{ pruned: string[] }> { const list = (await readDeployments(client)).filter((e) => e.id !== id); list.push({ id, ts }); const pruned: string[] = []; if (list.length > retain) { const live = await client.get(LIVE_KEY); const excess = list.slice(0, list.length - retain); const recent = list.slice(list.length - retain); const keptOld: DeploymentEntry[] = []; for (const entry of excess) { if (entry.id === live) { keptOld.push(entry); // never GC the live deployment continue; } await client.delete(snapshotKey(entry.id)); await client.delete(revisionKey(entry.id)); pruned.push(entry.id); } await client.put(DEPLOYMENTS_KEY, JSON.stringify([...keptOld, ...recent])); } else { await client.put(DEPLOYMENTS_KEY, JSON.stringify(list)); } return { pruned }; }