1/**2 * R2 object storage for the two heavy payload kinds. D1 keeps thin index3 * rows; bytes live in the BLOBS bucket:4 *5 * src/<sha256>.js script sources (content-addressed, immutable)6 * metrics/<key>.json.gz sim metrics, content-addressed by REPLAY7 * IDENTITY: key = sha256(seed + config + slot8 * blob-hashes in slot order)9 *10 * The replay key is always derived SERVER-SIDE from the sim row — an upload11 * can only land at the identity its sim's locked hashes+seed produce, and12 * sims with identical roster+seed share one metrics object.13 *14 * Legacy rows (pre-R2) still carry inline payloads in D1; every reader here15 * falls back to them, and the nightly sweep backfills them into R2.16 */17import type { Env } from './env';18import { sha256Hex } from './util';1920export const srcKey = (hash: string): string => `src/${hash}.js`;21export const metricsKey = (replayKey: string): string => `metrics/${replayKey}.json.gz`;2223/** Replay identity of a sim: seed + config + locked slot hashes, slot order. */24export async function replayKeyFor(25 seed: string,26 configJson: string,27 slotHashes: readonly string[],28): Promise<string> {29 return sha256Hex(`${seed}\n${configJson}\n${slotHashes.join(',')}`);30}3132export async function putSource(env: Env, hash: string, source: string): Promise<void> {33 await env.BLOBS.put(srcKey(hash), source);34}3536/** R2 first, D1 legacy row fallback. */37export async function getSourceAnywhere(env: Env, hash: string): Promise<string | null> {38 const obj = await env.BLOBS.get(srcKey(hash));39 if (obj !== null) return obj.text();40 const row = await env.DB41 .prepare(`SELECT source FROM blobs WHERE hash = ?1`)42 .bind(hash)43 .first<{ source: string }>();44 return row === null || row.source === '' ? null : row.source;45}4647/** Resolve many sources (sim bundles); rows come from the D1 index. */48export async function resolveSources(49 env: Env,50 rows: ReadonlyArray<{ hash: string; source: string; in_r2: number }>,51): Promise<Record<string, string>> {52 const out: Record<string, string> = {};53 for (const row of rows) {54 if (row.in_r2 === 0 && row.source !== '') {55 out[row.hash] = row.source;56 continue;57 }58 const obj = await env.BLOBS.get(srcKey(row.hash));59 // A missing object with an in_r2 marker is data loss; surface loudly.60 if (obj === null) {61 console.warn('blob missing from R2:', row.hash);62 out[row.hash] = row.source;63 } else {64 out[row.hash] = await obj.text();65 }66 }67 return out;68}6970/** Store gzipped metrics at the replay key unless already present. */71export async function putMetricsGzIfAbsent(72 env: Env,73 replayKey: string,74 gz: ArrayBuffer | Uint8Array,75): Promise<void> {76 const key = metricsKey(replayKey);77 // Deterministic content: a lost race writes identical bytes, so the78 // head-then-put is an optimization, not a correctness gate.79 if ((await env.BLOBS.head(key)) !== null) return;80 await env.BLOBS.put(key, gz);81}8283export async function getMetricsGz(env: Env, replayKey: string): Promise<Uint8Array | null> {84 const obj = await env.BLOBS.get(metricsKey(replayKey));85 if (obj === null) return null;86 return new Uint8Array(await obj.arrayBuffer());87}8889/** Best-effort bulk delete (sweep GC of orphaned sources). */90export async function deleteSources(env: Env, hashes: readonly string[]): Promise<void> {91 if (hashes.length === 0) return;92 for (let i = 0; i < hashes.length; i += 900) {93 await env.BLOBS.delete(hashes.slice(i, i + 900).map(srcKey));94 }95}