import type { Database } from "bun:sqlite"; import { type Bucket, BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts"; import { filesAt, objectBytes, tarballGeneration } from "../mirror/queries.ts"; import { knownSave } from "../mirror/store.ts"; import { buildTarball } from "./tar.ts"; const tarballsOf = (repo: string) => `${KEPR_PREFIX}tarballs/${repo}/`; /** * A save's tarball, kept in the bucket under Kepr's own folder (June, 2026-09-30) as * `.kepr/tarballs///.tar.gz` once first requested (`tarballGeneration`). * If the bucket refuses the write nothing is kept, and each request builds it again. Null when the * save is gone from the mirror, as it is for a moment while a rebuild runs. */ export async function cachedTarball( bucket: Bucket, db: Database, repo: string, saveId: string, ): Promise { for (;;) { const generation = tarballGeneration(db); const key = `${tarballsOf(repo)}${generation}/${saveId}.tar.gz`; const stored = await bucket.get(key); // A strip, a hidden-path change or a rebuild may land during any await, and the sync's thread // can land one between any two statements: the generation and the files are read in one // snapshot, so what is built matches the key it files under, and bytes are served only for // the generation still current. const read = db.transaction(() => { if (tarballGeneration(db) !== generation) return "moved"; if (stored) return "stored"; if (!knownSave(db, saveId)) return "unknown"; return filesAt(db, saveId).map((file) => { const bytes = objectBytes(db, file.hash); if (!bytes) throw new Error(`${repo}: object ${file.hash} is missing from the mirror`); return { path: file.path, bytes, executable: file.executable }; }); })(); if (read === "moved") continue; if (read === "stored") return stored; if (read === "unknown") return null; const tarball = buildTarball(`${repo}-${saveId.slice(0, 8)}`, read); try { await bucket.put(key, tarball, "application/gzip"); } catch (error) { if (!(error instanceof BucketWriteError)) throw error; } if (tarballGeneration(db) === generation) return tarball; } } /** Deletes a repo's kept tarballs to free space; stale ones are never looked up anyway. */ export async function dropTarballs(bucket: Bucket, repo: string): Promise { for (const key of await bucket.list(tarballsOf(repo))) await bucket.remove(key); }