import type { Database } from "bun:sqlite"; import { type Bucket, BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts"; import { filesAt, objectBytes, stripCount } from "../mirror/queries.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. The generation is how * many strip events the mirror holds, so once a strip lands no older file is ever looked up again, * even if Kepr stops before the sync's cleanup runs. Without a write key nothing is kept, and each * request builds it again. */ export async function cachedTarball( bucket: Bucket, db: Database, repo: string, saveId: string, ): Promise { const stored = await bucket.get(`${tarballsOf(repo)}${stripCount(db)}/${saveId}.tar.gz`); if (stored) return stored; // Read again after the await: the generation and the build below run in one synchronous go, so // a strip landing meanwhile can't file new contents under the old generation or the reverse. const key = `${tarballsOf(repo)}${stripCount(db)}/${saveId}.tar.gz`; const files = 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 }; }); const tarball = buildTarball(`${repo}-${saveId.slice(0, 8)}`, files); try { await bucket.put(key, tarball, "application/gzip"); } catch (error) { if (!(error instanceof BucketWriteError)) throw error; } return tarball; } /** Deletes a repo's kept tarballs: after a strip, or from the console. They rebuild on request. */ export async function dropTarballs(bucket: Bucket, repo: string): Promise { for (const key of await bucket.list(tarballsOf(repo))) await bucket.remove(key); }