1a625426812f

delete one file of a build, rewriting its release record first (v3.0.160)

time
2026-09-30 23:16 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
0fb42c17ed78
7 files package.json+1 −1src/admin/repos.ts+45 −0src/bucket/reader.ts+9 −6src/mirror/store.ts+8 −0src/releases/releases.ts+55 −2tests/console.test.ts+53 −0tests/repotext.test.ts+2 −1
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.159",3+	"version": "3.0.160",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+45 −0
@@ -10,6 +10,8 @@1010 import { formatDate, plural } from "../pages/layout.ts";1111 import {1212 	deleteRelease,13+	deleteReleaseFile,14+	LastReleaseFile,1315 	type OperatorRelease,1416 	operatorReleases,1517 	setRelease,@@ -144,6 +146,37 @@144146 		}145147 		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#builds`, 303);146148 	});149+150+	app.post("/admin/releases/:repo/:version/:platform/delete", async (c) => {151+		const found = findRelease(ctx, c.req.param("repo"), c.req.param("version"));152+		const platform = c.req.param("platform");153+		if (!found?.release.downloads.some(([p]) => p === platform)) return c.notFound();154+		const { repo, mirror, release } = found;155+		const refuse = (status: 400 | 503, error: string) =>156+			c.html(157+				reposPage(c, ctx, `${repo.name} ${release.version} ${platform}: ${error}`, repo.name),158+				status,159+			);160+		if ((await textFields(c)).confirm !== "1") return refuse(400, "tick the box to delete it.");161+		try {162+			await deleteReleaseFile(163+				ctx.sync.bucket,164+				ctx.kepr,165+				mirror,166+				repo.name,167+				release.version,168+				platform,169+			);170+		} catch (error) {171+			if (error instanceof LastReleaseFile) return refuse(400, error.message);172+			if (error instanceof BucketWriteError) return refuse(503, error.message);173+			throw error;174+		}175+		return c.redirect(176+			`/admin/repos?repo=${repo.name}&saved=1#${repo.name}-${release.version}`,177+			303,178+		);179+	});147180 }148181 149182 /**@@ -309,6 +342,18 @@309342 }<label class="issues-label">${html`<input type="checkbox"`}${html` name="hidden" value="1"`}${release.hidden ? html` checked` : ""}> hidden</label>310343 <button class="issues-submit" type="submit">save ${release.version}</button>311344 </form>345+${346+	release.downloads.length > 1 &&347+	release.downloads.map(348+		([platform]) => html`<form class="issues-form" method="post"349+action="/admin/releases/${repo}/${release.version}/${platform}/delete">350+${formField(c)}351+<label class="issues-label"><input type="checkbox" name="confirm" value="1" required>352+delete the ${platform} file from the bucket</label>353+<button class="issues-submit" type="submit">delete ${platform}</button>354+</form>`,355+	)356+}312357 <form class="issues-form" method="post" action="/admin/releases/${repo}/${release.version}/delete">313358 ${formField(c)}314359 <label class="issues-label"><input type="checkbox" name="confirm" value="1" required>

view file

src/bucket/reader.ts+9 −6
@@ -8,8 +8,8 @@88  * operations, plus a short-lived download URL so Kepr never streams release files itself (§22.2).99  * Kepr's own files (uploads) live under `KEPR_PREFIX` (June, 2026-09-29); no Koh repo can have1010  * that name, since Kepr and Koh both need a `head` for one. Beyond it Kepr touches only builds,11- * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files and moves12- * `releases/latest`.11+ * which Koh has no way to delete (June, 2026-09-30): it deletes a version's files, rewrites its12+ * record without a deleted one, and moves `releases/latest`.1313  */1414 export type Bucket = {1515 	get(key: string): Promise<Uint8Array | null>;@@ -18,7 +18,10 @@1818 	downloadUrl(key: string, filename: string, type: string): string;1919 	/** A five-minute URL that shows `key` in the page, or null when Kepr must serve the bytes itself. */2020 	viewUrl(key: string, type: string): string | null;21-	/** Writes one of Kepr's own files or a repo's `releases/latest`; any other key is refused. */21+	/**22+	 * Writes one of Kepr's own files, a repo's `releases/latest`, or a version's `release.json` when23+	 * one of its files is deleted; any other key is refused.24+	 */2225 	put(key: string, bytes: Uint8Array, type: string): Promise<void>;2326 	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */2427 	remove(key: string): Promise<void>;@@ -31,7 +34,7 @@3134 3235 const DOWNLOAD_SECONDS = 5 * 60;3336 34-const RELEASES_LATEST = /^[^/]+\/releases\/latest$/;37+const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;3538 const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;3639 3740 function requireWritable(key: string, releaseKey: RegExp): void {@@ -124,7 +127,7 @@124127 			return client.presign(key, { method: "GET", expiresIn: DOWNLOAD_SECONDS, type });125128 		},126129 		async put(key, bytes, type) {127-			requireWritable(key, RELEASES_LATEST);130+			requireWritable(key, RELEASE_WRITE);128131 			await requireWriter().file(key).write(bytes, { type });129132 		},130133 		async remove(key) {@@ -156,7 +159,7 @@156159 		},157160 		viewUrl: () => null,158161 		async put(key, bytes) {159-			requireWritable(key, RELEASES_LATEST);162+			requireWritable(key, RELEASE_WRITE);160163 			await Bun.write(join(root, key), bytes);161164 		},162165 		async remove(key) {

view file

src/mirror/store.ts+8 −0
@@ -167,6 +167,14 @@167167 	db.query("DELETE FROM releases WHERE version = ?").run(version);168168 }169169 170+/** Replaces a version's record after Kepr rewrote it in the bucket. */171+export function replaceRelease(db: Database, record: ReleaseRecord): void {172+	db.query("UPDATE releases SET record = ? WHERE version = ?").run(173+		JSON.stringify(record),174+		record.version,175+	);176+}177+170178 export function knownReleases(db: Database): Set<string> {171179 	const rows = db.query<{ version: string }, []>("SELECT version FROM releases").all();172180 	return new Set(rows.map((row) => row.version));

view file

src/releases/releases.ts+55 −2
@@ -1,8 +1,13 @@11 import type { Database } from "bun:sqlite";22 import type { Bucket } from "../bucket/reader.ts";3-import { listReleaseVersions, type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";3+import {4+	checkRelease,5+	listReleaseVersions,6+	type ReleaseFile,7+	readReleasesLatest,8+} from "../bucket/repo.ts";49 import { releaseRecords } from "../mirror/queries.ts";5-import { forgetRelease } from "../mirror/store.ts";10+import { forgetRelease, replaceRelease } from "../mirror/store.ts";611 712 /** A release as the public sees it: its notes are the operator's override when one is set (§24). */813 export type Release = {@@ -217,4 +222,52 @@217222 			kepr.query(`DELETE FROM ${table} WHERE repo = ? AND version = ?`).run(repo, version);218223 		}219224 	})();225+}226+227+/** Why a single file can't be deleted: the last one goes with its version, never alone. */228+export class LastReleaseFile extends Error {}229+230+/**231+ * Deletes one file of a build (2026-09-30). The record is rewritten without it first, so a failure232+ * part-way leaves at most a file nothing lists; its count goes too. Runs in the same queue as whole233+ * deletes, so neither reads a record the other is rewriting.234+ */235+export function deleteReleaseFile(236+	bucket: Bucket,237+	kepr: Database,238+	mirror: Database,239+	repo: string,240+	version: string,241+	platform: string,242+): Promise<void> {243+	const run = deleting.then(() => removeReleaseFile(bucket, kepr, mirror, repo, version, platform));244+	deleting = run.catch(() => {});245+	return run;246+}247+248+async function removeReleaseFile(249+	bucket: Bucket,250+	kepr: Database,251+	mirror: Database,252+	repo: string,253+	version: string,254+	platform: string,255+): Promise<void> {256+	const key = `${repo}/releases/${version}/release.json`;257+	const bytes = await bucket.get(key);258+	if (bytes === null) throw new Error(`${key} is missing`);259+	// Rewritten from the raw JSON, so fields Kepr doesn't read survive the edit.260+	const raw = JSON.parse(new TextDecoder().decode(bytes)) as { files: ReleaseFile[] };261+	const file = raw.files.find((f) => f.platform === platform);262+	if (file) {263+		if (raw.files.length === 1) throw new LastReleaseFile("delete the version instead");264+		raw.files = raw.files.filter((f) => f !== file);265+		const json = `${JSON.stringify(raw, null, 2)}\n`;266+		await bucket.put(key, new TextEncoder().encode(json), "application/json");267+		replaceRelease(mirror, checkRelease(repo, version, raw));268+		await bucket.remove(`${repo}/releases/${version}/${platform}/${file.name}`);269+	}270+	kepr271+		.query("DELETE FROM downloads WHERE repo = ? AND version = ? AND platform = ?")272+		.run(repo, version, platform);220273 }

view file

tests/console.test.ts+53 −0
@@ -376,6 +376,59 @@376376 	});377377 });378378 379+describe("deleting one file of a build", () => {380+	let inst: Instance;381+	let operator: Browser;382+	let bucketDir: string;383+	const record = async () =>384+		JSON.parse(await Bun.file(join(bucketDir, "app/releases/1.0.0/release.json")).text());385+	const removeFile = (platform: string, fields: Record<string, string> = { confirm: "1" }) =>386+		operator.post(`/admin/releases/app/1.0.0/${platform}/delete`, fields);387+388+	beforeAll(async () => {389+		const repos = await makeRepos(["app"]);390+		bucketDir = repos.bucketDir;391+		await writeRelease(bucketDir, "app", {392+			version: "1.0.0",393+			save: repos.saves.app as string,394+			files: [395+				{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) },396+				{ platform: "macos-aarch64", name: "app", bytes: new Uint8Array([2]) },397+			],398+		});399+		inst = await instance(bucketDir);400+		operator = new Browser(inst.app);401+		await operator.signIn();402+		await operator.post("/admin/repos/app", { public: "1" });403+		await inst.app.request("/app/releases/1.0.0/macos-aarch64");404+	});405+406+	test("needs its confirmation, and an unknown platform is a 404", async () => {407+		expect((await removeFile("macos-aarch64", {})).status).toBe(400);408+		expect((await removeFile("windows-x86_64")).status).toBe(404);409+		expect((await record()).files).toHaveLength(2);410+	});411+412+	test("the file leaves the record, the bucket, the counts and the public routes", async () => {413+		const response = await removeFile("macos-aarch64");414+		expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.0.0");415+		const files = (await record()).files.map((file: { platform: string }) => file.platform);416+		expect(files).toEqual(["linux-x86_64"]);417+		const gone = join(bucketDir, "app/releases/1.0.0/macos-aarch64/app");418+		expect(await Bun.file(gone).exists()).toBe(false);419+		expect(420+			inst.kepr.query("SELECT * FROM downloads WHERE platform = 'macos-aarch64'").all(),421+		).toEqual([]);422+		expect((await inst.app.request("/app/releases/1.0.0/macos-aarch64")).status).toBe(404);423+		expect((await inst.app.request("/app/releases/1.0.0/linux-x86_64")).status).toBe(302);424+	});425+426+	test("the last file can't go alone", async () => {427+		expect((await removeFile("linux-x86_64")).status).toBe(400);428+		expect((await record()).files).toHaveLength(1);429+	});430+});431+379432 describe("deleting builds when a step fails or two run at once", () => {380433 	let inst: Instance;381434 	let bucketDir: string;

view file

tests/repotext.test.ts+2 −1
@@ -140,7 +140,7 @@140140 	const { bucketDir } = await makeRepos(["app"]);141141 	const folder = openBucket({ kind: "folder", path: bucketDir });142142 	const refused = ["app/head", ".kepr/../app/head", ".kepr//x", "x/.kepr/y", "app/releases/.."];143-	for (const key of [...refused, "app/releases/1.0.0/release.json"]) {143+	for (const key of [...refused, "app/releases/1.0.0/app", "app/releases/1.0.0/x/release.json"]) {144144 		await expect(folder.put(key, new Uint8Array([1]), "image/png")).rejects.toBeInstanceOf(145145 			BucketWriteError,146146 		);@@ -150,6 +150,7 @@150150 	}151151 	await folder.put("app/releases/latest", new TextEncoder().encode("1.0.0"), "text/plain");152152 	expect(await Bun.file(`${bucketDir}/app/releases/latest`).text()).toBe("1.0.0");153+	await folder.put("app/releases/1.0.0/release.json", new Uint8Array([1]), "application/json");153154 	await folder.put(".kepr/x", new Uint8Array([1]), "text/plain");154155 	await folder.remove(".kepr/x");155156 	expect(await Bun.file(`${bucketDir}/.kepr/x`).exists()).toBe(false);

view file