02702253e7ae

keep built tarballs in the bucket under .kepr/tarballs instead of the data directory (v3.0.145)

time
2026-09-30 13:53 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
9a44722de6e7
20 files package.json+1 −1src/bucket/sync.ts+1 −2src/main.ts+0 −1src/server/app.ts+2 −2src/tarball/archive.ts+5 −4src/tarball/cache.ts+24 −22tests/browser.ts+0 −1tests/contract.test.ts+0 −1tests/front.test.ts+0 −1tests/install.test.ts+0 −1tests/issues.test.ts+0 −1tests/kohpaths.test.ts+0 −1tests/kohrelease.test.ts+0 −1tests/kohremote.test.ts+0 −1tests/kohsteal.test.ts+0 −1tests/kohupdate.test.ts+0 −1tests/pages.test.ts+1 −1tests/releases.test.ts+0 −2tests/sync.test.ts+0 −2tests/tarball.test.ts+13 −8
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.144",3+	"version": "3.0.145",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/bucket/sync.ts+1 −2
@@ -39,7 +39,6 @@3939 	bucket: Bucket;4040 	kepr: Database;4141 	mirrorDir: string;42-	tarballDir: string;4342 };4443 4544 let running: Promise<void> | null = null;@@ -67,7 +66,7 @@6766 		} else {6867 			try {6968 				const events = await syncRepo(target.bucket, openMirror(target.mirrorDir, repo), repo);70-				if (events.some((event) => event.kind === "strip")) dropTarballs(target.tarballDir, repo);69+				if (events.some((event) => event.kind === "strip")) await dropTarballs(target.bucket, repo);7170 			} catch (cause) {7271 				error = cause instanceof Error ? cause.message : String(cause);7372 			}

view file

src/main.ts+0 −1
@@ -18,7 +18,6 @@1818 		bucket,1919 		kepr,2020 		mirrorDir: join(config.dataDir, "mirror"),21-		tarballDir: join(config.dataDir, "tarballs"),2221 	});2322 	// Bun.serve reads the port from $PORT (default 3000).2423 	const server = Bun.serve({ fetch: createApp(config, kepr, bucket).fetch });

view file

src/server/app.ts+2 −2
@@ -119,7 +119,7 @@119119 		config,120120 		configPath,121121 		dataDir: config.dataDir,122-		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir, tarballDir: join(config.dataDir, "tarballs") },122+		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir },123123 	};124124 	registerStatus(app, admin);125125 	registerRepos(app, admin);@@ -142,7 +142,7 @@142142 	registerArchivesPage(app, ctx);143143 	registerReleasesPage(app, ctx);144144 	registerIssuePages(app, ctx);145-	registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));145+	registerTarballRoutes(app, ctx, bucket);146146 	registerReleaseRoutes(app, ctx, bucket);147147 	return app;148148 }

view file

src/tarball/archive.ts+5 −4
@@ -1,4 +1,5 @@11 import type { Hono } from "hono";2+import type { Bucket } from "../bucket/reader.ts";23 import { headId, saveByPrefix } from "../mirror/queries.ts";34 import { notFound, openPublicRepo, type PageContext } from "../pages/layout.ts";45 import { countInstall } from "../releases/releases.ts";@@ -8,7 +9,7 @@89 // so no shared cache keeps it after the repo turns private (§20).910 const TARBALL_CACHE = "private, max-age=31536000";1011 11-export function registerTarballRoutes(app: Hono, ctx: PageContext, tarballDir: string): void {12+export function registerTarballRoutes(app: Hono, ctx: PageContext, bucket: Bucket): void {1213 	app.get("/:repo/archive/HEAD.tar.gz", (c) => {1314 		const repo = openPublicRepo(ctx, c.req.param("repo"));1415 		const head = repo && headId(repo.mirror);@@ -20,7 +21,7 @@2021 		return c.redirect(path, 302);2122 	});2223 23-	app.get("/:repo/archive/:id{[0-9a-f]+\\.tar\\.gz}", (c) => {24+	app.get("/:repo/archive/:id{[0-9a-f]+\\.tar\\.gz}", async (c) => {2425 		const repo = openPublicRepo(ctx, c.req.param("repo"));2526 		const id = c.req.param("id").replace(/\.tar\.gz$/, "");2627 		const save = repo && saveByPrefix(repo.mirror, id);@@ -30,12 +31,12 @@3031 			c.header("Cache-Control", "public, max-age=60");3132 			return c.redirect(`/${name}/archive/${save.id}.tar.gz`, 302);3233 		}33-		const bytes = cachedTarball(tarballDir, repo.mirror, name, save.id);34+		const bytes = await cachedTarball(bucket, repo.mirror, name, save.id);3435 		// §22.2: a Nix fetch is an install. Only the tarball itself counts, not the redirects to it.3536 		if (c.req.header("User-Agent")?.includes("nix/")) countInstall(ctx.kepr, name);3637 		c.header("Content-Type", "application/gzip");3738 		c.header("Content-Disposition", `attachment; filename="${name}-${save.id.slice(0, 8)}.tar.gz"`);3839 		c.header("Cache-Control", TARBALL_CACHE);39-		return c.body(bytes);40+		return c.body(bytes.slice());4041 	});4142 }

view file

src/tarball/cache.ts+24 −22
@@ -1,41 +1,43 @@11 import type { Database } from "bun:sqlite";2-import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";3-import { join } from "node:path";2+import { type Bucket, BucketWriteError, KEPR_PREFIX } from "../bucket/reader.ts";43 import { filesAt, objectBytes, stripCount } from "../mirror/queries.ts";54 import { buildTarball } from "./tar.ts";65 6+const tarballsOf = (repo: string) => `${KEPR_PREFIX}tarballs/${repo}/`;7+78 /**8- * A save's tarball, built on first request into `<tarballDir>/<repo>/<generation>/<id>.tar.gz`. The9- * generation is how many strip events the mirror holds, so once a strip lands no older file is ever10- * looked up again, even if Kepr stops before the sync's cleanup runs. Built and written in one11- * synchronous run, so a strip can't land mid-build either.9+ * A save's tarball, kept in the bucket under Kepr's own folder (June, 2026-09-30) as10+ * `.kepr/tarballs/<repo>/<generation>/<id>.tar.gz` once first requested. The generation is how11+ * many strip events the mirror holds, so once a strip lands no older file is ever looked up again,12+ * even if Kepr stops before the sync's cleanup runs. Without a write key nothing is kept, and each13+ * request builds it again.1214  */13-export function cachedTarball(14-	tarballDir: string,15+export async function cachedTarball(16+	bucket: Bucket,1517 	db: Database,1618 	repo: string,1719 	saveId: string,18-): Uint8Array<ArrayBuffer> {19-	const dir = join(tarballDir, repo, String(stripCount(db)));20-	const path = join(dir, `${saveId}.tar.gz`);21-	try {22-		return readFileSync(path);23-	} catch (error) {24-		if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;25-	}20+): Promise<Uint8Array> {21+	const stored = await bucket.get(`${tarballsOf(repo)}${stripCount(db)}/${saveId}.tar.gz`);22+	if (stored) return stored;23+	// Read again after the await: the generation and the build below run in one synchronous go, so24+	// a strip landing meanwhile can't file new contents under the old generation or the reverse.25+	const key = `${tarballsOf(repo)}${stripCount(db)}/${saveId}.tar.gz`;2626 	const files = filesAt(db, saveId).map((file) => {2727 		const bytes = objectBytes(db, file.hash);2828 		if (!bytes) throw new Error(`${repo}: object ${file.hash} is missing from the mirror`);2929 		return { path: file.path, bytes, executable: file.executable };3030 	});3131 	const tarball = buildTarball(`${repo}-${saveId.slice(0, 8)}`, files);32-	mkdirSync(dir, { recursive: true });33-	writeFileSync(`${path}.tmp`, tarball);34-	renameSync(`${path}.tmp`, path);32+	try {33+		await bucket.put(key, tarball, "application/gzip");34+	} catch (error) {35+		if (!(error instanceof BucketWriteError)) throw error;36+	}3537 	return tarball;3638 }3739 38-/** Clears a repo's cached tarballs after a strip; cleanup only, since the new generation never reads them. */39-export function dropTarballs(tarballDir: string, repo: string): void {40-	rmSync(join(tarballDir, repo), { recursive: true, force: true });40+/** Deletes a repo's kept tarballs: after a strip, or from the console. They rebuild on request. */41+export async function dropTarballs(bucket: Bucket, repo: string): Promise<void> {42+	for (const key of await bucket.list(tarballsOf(repo))) await bucket.remove(key);4143 }

view file

tests/browser.ts+0 −1
@@ -69,7 +69,6 @@6969 		bucket: openBucket(bucket),7070 		kepr,7171 		mirrorDir: join(dir, "mirror"),72-		tarballDir: join(dir, "tarballs"),7372 	});7473 	const config: Config = {7574 		domain: "kepr.example",

view file

tests/contract.test.ts+0 −1
@@ -94,7 +94,6 @@9494 		bucket: openBucket({ kind: "folder", path: bucketDir }),9595 		kepr,9696 		mirrorDir: join(dataDir, "mirror"),97-		tarballDir: join(dataDir, "tarballs"),9897 	});9998 	setup(kepr);10099 	const config = {

view file

tests/front.test.ts+0 −1
@@ -45,7 +45,6 @@4545 		bucket,4646 		kepr,4747 		mirrorDir: join(dataDir, "mirror"),48-		tarballDir: join(dataDir, "tarballs"),4948 	});5049 	kepr.run("UPDATE repos SET public = 1, category = 'tools' WHERE name = 'alpha'");5150 	kepr.run("UPDATE repos SET category = 'secretcat' WHERE name = 'vault'");

view file

tests/install.test.ts+0 −1
@@ -45,7 +45,6 @@4545 		bucket: openBucket({ kind: "folder", path: bucketDir }),4646 		kepr: db,4747 		mirrorDir: join(dataDir, "mirror"),48-		tarballDir: join(dataDir, "tarballs"),4948 	});5049 	db.run("UPDATE repos SET public = 1");5150 	kepr = Bun.serve({ port: 0, fetch: () => new Response("starting") });

view file

tests/issues.test.ts+0 −1
@@ -140,7 +140,6 @@140140 			bucket: openBucket(bucket),141141 			kepr,142142 			mirrorDir: join(dataDir, "mirror"),143-			tarballDir: join(dataDir, "tarballs"),144143 		});145144 		kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");146145 		app = createApp(

view file

tests/kohpaths.test.ts+0 −1
@@ -63,7 +63,6 @@6363 		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),6464 		kepr,6565 		mirrorDir: join(dataDir, "mirror"),66-		tarballDir: join(dataDir, "tarballs"),6766 	});6867 	expect(kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({ last_sync_error: null });6968 

view file

tests/kohrelease.test.ts+0 −1
@@ -77,7 +77,6 @@7777 			bucket: openBucket({ kind: "folder", path: bucketDir }),7878 			kepr,7979 			mirrorDir: join(dataDir, "mirror"),80-			tarballDir: join(dataDir, "tarballs"),8180 		});8281 		kepr.run(`UPDATE repos SET public = 1 WHERE name = '${REPO}'`);8382 		app = createApp(

view file

tests/kohremote.test.ts+0 −1
@@ -26,7 +26,6 @@2626 		bucket: openBucket(inst.config.bucket),2727 		kepr: inst.kepr,2828 		mirrorDir: join(inst.dataDir, "mirror"),29-		tarballDir: join(inst.dataDir, "tarballs"),3029 	});3130 }3231 

view file

tests/kohsteal.test.ts+0 −1
@@ -129,7 +129,6 @@129129 						bucket: openBucket(bucket),130130 						kepr,131131 						mirrorDir: join(dataDir, "mirror"),132-						tarballDir: join(dataDir, "tarballs"),133132 					});134133 				await sync();135134 				kepr.query("UPDATE repos SET public = 1 WHERE name = ?").run(REPO);

view file

tests/kohupdate.test.ts+0 −1
@@ -103,7 +103,6 @@103103 			bucket: openBucket({ kind: "folder", path: space.bucketDir }),104104 			kepr,105105 			mirrorDir: join(dataDir, "mirror"),106-			tarballDir: join(dataDir, "tarballs"),107106 		});108107 		kepr.run("UPDATE repos SET public = 1 WHERE name = 'koh'");109108 

view file

tests/pages.test.ts+1 −1
@@ -62,7 +62,7 @@6262 	mirrorDir = join(dataDir, "mirror");6363 	for (const bucketDir of [fx.bucketDir, secretRepo.bucketDir, large.bucketDir, tail.bucketDir]) {6464 		const bucket = openBucket({ kind: "folder", path: bucketDir });65-		await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });65+		await syncAll({ bucket, kepr, mirrorDir });6666 	}6767 	kepr.run("UPDATE repos SET public = 1, homepage = 'https://demo.example' WHERE name = 'demo'");6868 	kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");

view file

tests/releases.test.ts+0 −2
@@ -36,7 +36,6 @@3636 			bucket: openBucket({ kind: "folder", path: bucketDir }),3737 			kepr: openKeprDb(dataDir),3838 			mirrorDir: join(dataDir, "mirror"),39-			tarballDir: join(dataDir, "tarballs"),4039 		};4140 		for (const version of ["1.9.0", "1.10.0", "1.2.3"]) {4241 			const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }];@@ -426,7 +425,6 @@426425 		bucket,427426 		kepr,428427 		mirrorDir: join(dataDir, "mirror"),429-		tarballDir: join(dataDir, "tarballs"),430428 	});431429 	setup(kepr);432430 	const app = createApp(

view file

tests/sync.test.ts+0 −2
@@ -30,7 +30,6 @@3030 		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),3131 		kepr: openKeprDb(dataDir),3232 		mirrorDir: join(dataDir, "mirror"),33-		tarballDir: join(dataDir, "tarballs"),3433 	};3534 	return { ...target, mirror: () => openMirror(target.mirrorDir, fx.repo) };3635 }@@ -253,7 +252,6 @@253252 		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),254253 		kepr: inst.kepr,255254 		mirrorDir: join(inst.dataDir, "mirror"),256-		tarballDir: join(inst.dataDir, "tarballs"),257255 	});258256 	expect(history(mirror, 1, 0)[0]?.message).toBe("a later save");259257 

view file

tests/tarball.test.ts+13 −8
@@ -20,6 +20,9 @@2020 let dataDir: string;2121 const get = (path: string) => app.request(path);2222 const bytesOf = async (path: string) => new Uint8Array(await (await get(path)).arrayBuffer());23+// Where a tarball of HEAD is kept in the folder bucket, for a strip generation.24+const stored = (generation: string) =>25+	join(fx.bucketDir, ".kepr", "tarballs", "tarry", generation, `${head}.tar.gz`);2326 2427 // Every root-level file on Kepr 2's strip list, the .agent folder, and the look-alikes that must stay.2528 const EXTRA: Record<string, string> = {@@ -56,7 +59,6 @@5659 		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),5760 		kepr,5861 		mirrorDir: join(dataDir, "mirror"),59-		tarballDir: join(dataDir, "tarballs"),6062 	};6163 	await syncAll(target);6264 	kepr.run("UPDATE repos SET public = 1");@@ -231,20 +233,23 @@231233 	test("is the same bytes when rebuilt, and in another process", async () => {232234 		const first = await bytesOf(`/tarry/archive/${head}.tar.gz`);233235 		const { dropTarballs } = await import("../src/tarball/cache.ts");234-		dropTarballs(target.tarballDir, "tarry");236+		await dropTarballs(target.bucket, "tarry");237+		expect(existsSync(stored("0"))).toBe(false);235238 		expect(await bytesOf(`/tarry/archive/${head}.tar.gz`)).toEqual(first);236239 		// Every path the other process touches is a fresh temp dir, its working directory included,237240 		// so nothing it writes can land in the repo.238241 		const script = `import { cachedTarball } from "${join(import.meta.dir, "../src/tarball/cache.ts")}";242+import { openBucket } from "${join(import.meta.dir, "../src/bucket/reader.ts")}";239243 import { openMirror } from "${join(import.meta.dir, "../src/mirror/store.ts")}";240-const { TARBALL_DIR, MIRROR_DIR, SAVE_ID } = process.env;241-process.stdout.write(cachedTarball(TARBALL_DIR, openMirror(MIRROR_DIR, "tarry"), "tarry", SAVE_ID));`;244+const { BUCKET_DIR, MIRROR_DIR, SAVE_ID } = process.env;245+const bucket = openBucket({ kind: "folder", path: BUCKET_DIR });246+process.stdout.write(await cachedTarball(bucket, openMirror(MIRROR_DIR, "tarry"), "tarry", SAVE_ID));`;242247 		const scratch = await mkdtemp(join(tmpdir(), "kepr-tb-"));243248 		const other = Bun.spawnSync(["bun", "-e", script], {244249 			cwd: scratch,245250 			env: {246251 				PATH: process.env.PATH,247-				TARBALL_DIR: join(scratch, "tarballs"),252+				BUCKET_DIR: join(scratch, "bucket"),248253 				MIRROR_DIR: target.mirrorDir,249254 				SAVE_ID: head,250255 			},@@ -255,7 +260,7 @@255260 256261 	test("after a strip, an old cached tarball is never served, even if its cleanup never ran", async () => {257262 		await get(`/tarry/archive/${head}.tar.gz`);258-		const before = join(target.tarballDir, "tarry", "0", `${head}.tar.gz`);263+		const before = stored("0");259264 		expect(existsSync(before)).toBe(true);260265 		await fx.strip("secret.txt");261266 		// syncRepo lands the strip without syncAll's cleanup: a crash between the two.@@ -264,14 +269,14 @@264269 		const listed = tar(["-tzf", "-"], await bytesOf(`/tarry/archive/${head}.tar.gz`));265270 		expect(listed).not.toContain("secret.txt");266271 		expect(listed).toContain("src/main.ts");267-		expect(existsSync(join(target.tarballDir, "tarry", "1", `${head}.tar.gz`))).toBe(true);272+		expect(existsSync(stored("1"))).toBe(true);268273 	});269274 270275 	test("a sync that lands a strip clears the repo's old cached tarballs", async () => {271276 		await get(`/tarry/archive/${head}.tar.gz`);272277 		await fx.strip("vendor/big.c");273278 		await syncAll(target);274-		expect(existsSync(join(target.tarballDir, "tarry"))).toBe(false);279+		expect(existsSync(stored("1"))).toBe(false);275280 		expect(tar(["-tzf", "-"], await bytesOf(`/tarry/archive/${head}.tar.gz`))).not.toContain(276281 			"big.c",277282 		);

view file