ba443d076bce

delete builds one at a time, moving latest first and the record last, so a failed delete retries cleanly (v3.0.152)

time
2026-09-30 15:44 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
codex review fixes
parent
2b4b50250d6d
3 files package.json+1 −1src/releases/releases.ts+30 −11tests/console.test.ts+53 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.151",3+	"version": "3.0.152",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/releases/releases.ts+30 −11
@@ -1,6 +1,6 @@11 import type { Database } from "bun:sqlite";22 import type { Bucket } from "../bucket/reader.ts";3-import { type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";3+import { listReleaseVersions, type ReleaseFile, readReleasesLatest } from "../bucket/repo.ts";44 import { releaseRecords } from "../mirror/queries.ts";55 import { forgetRelease } from "../mirror/store.ts";66 @@ -166,31 +166,50 @@166166 		.run(repo, version, hidden ? 1 : 0, notes);167167 }168168 169+// Deletes run one at a time, so two can't each move `latest` from a reading the other outdated.170+let deleting: Promise<unknown> = Promise.resolve();171+169172 /**170- * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to: its record first,171- * so the version stops existing (§11.2) even if a later delete fails, then its files. When172- * `releases/latest` named it, latest moves to the newest version left, or goes with the last one.173- * Kepr's own settings and counts for it go too.173+ * Deletes a build from the bucket (June, 2026-09-30), since Koh has no way to. `releases/latest`174+ * moves first, to the newest other version with a record (or goes with the last), then the files,175+ * then the record: until the record goes the version still exists (§11.2), so a failure part-way176+ * leaves it whole and deleting it again finishes the job. Kepr's settings and counts for it go too.174177  */175-export async function deleteRelease(178+export function deleteRelease(176179 	bucket: Bucket,177180 	kepr: Database,178181 	mirror: Database,179182 	repo: string,180183 	version: string,181184 ): Promise<void> {182-	const prefix = `${repo}/releases/${version}/`;183-	await bucket.remove(`${prefix}release.json`);184-	for (const key of await bucket.list(prefix)) await bucket.remove(key);185+	const run = deleting.then(() => removeRelease(bucket, kepr, mirror, repo, version));186+	deleting = run.catch(() => {});187+	return run;188+}189+190+async function removeRelease(191+	bucket: Bucket,192+	kepr: Database,193+	mirror: Database,194+	repo: string,195+	version: string,196+): Promise<void> {185197 	if ((await readReleasesLatest(bucket, repo)) === version) {186-		const newest = releaseRecords(mirror)187-			.map((record) => record.version)198+		const newest = (await listReleaseVersions(bucket, repo))188199 			.filter((other) => other !== version)189200 			.sort((a, b) => compareVersions(b, a))[0];190201 		const latest = `${repo}/releases/latest`;202+		// ponytail: an unconditional write, as the reader has no put_if_match; a Koh publish landing203+		// between the read above and this write loses `latest` until it offers again.191204 		if (newest) await bucket.put(latest, new TextEncoder().encode(newest), "text/plain");192205 		else await bucket.remove(latest);193206 	}207+	const prefix = `${repo}/releases/${version}/`;208+	const record = `${prefix}release.json`;209+	for (const key of await bucket.list(prefix)) {210+		if (key !== record) await bucket.remove(key);211+	}212+	await bucket.remove(record);194213 	forgetRelease(mirror, version);195214 	kepr.transaction(() => {196215 		for (const table of ["release_settings", "downloads"]) {

view file

tests/console.test.ts+53 −0
@@ -1,6 +1,9 @@11 import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";22 import { join } from "node:path";3+import { type Bucket, openBucket } from "../src/bucket/reader.ts";34 import { fileIssue } from "../src/issues/store.ts";5+import { openMirror } from "../src/mirror/store.ts";6+import { deleteRelease, operatorReleases } from "../src/releases/releases.ts";47 import { Browser, type Instance, instance } from "./browser.ts";58 import { makeRepos, writeRelease } from "./fixture.ts";69 @@ -370,6 +373,56 @@370373 		expect((await remove("1.0.0")).status).toBe(303);371374 		expect(await Bun.file(join(bucketDir, "app/releases/latest")).exists()).toBe(false);372375 		expect((await inst.app.request("/app/releases/latest/version.txt")).status).toBe(404);376+	});377+});378+379+describe("deleting builds when a step fails or two run at once", () => {380+	let inst: Instance;381+	let bucketDir: string;382+	const folder = () => openBucket({ kind: "folder", path: bucketDir });383+	const mirror = () => openMirror(join(inst.dataDir, "mirror"), "app");384+	const versions = () => operatorReleases(inst.kepr, mirror(), "app").map((r) => r.version);385+	const latest = () => Bun.file(join(bucketDir, "app/releases/latest")).text();386+387+	beforeAll(async () => {388+		const repos = await makeRepos(["app"]);389+		bucketDir = repos.bucketDir;390+		for (const version of ["1.0.0", "2.0.0", "3.0.0", "4.0.0"]) {391+			await writeRelease(bucketDir, "app", {392+				version,393+				save: repos.saves.app as string,394+				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],395+			});396+		}397+		inst = await instance(bucketDir);398+	});399+400+	test("a failed file delete leaves the build whole, and deleting it again finishes", async () => {401+		const failing: Bucket = {402+			...folder(),403+			async remove(key) {404+				if (key.endsWith("/app")) throw new Error("network down");405+				return folder().remove(key);406+			},407+		};408+		await expect(deleteRelease(failing, inst.kepr, mirror(), "app", "4.0.0")).rejects.toThrow(409+			"network down",410+		);411+		expect(await Bun.file(join(bucketDir, "app/releases/4.0.0/release.json")).exists()).toBe(true);412+		expect(versions()).toContain("4.0.0");413+		expect(await latest()).toBe("3.0.0");414+		await deleteRelease(folder(), inst.kepr, mirror(), "app", "4.0.0");415+		expect(await Bun.file(join(bucketDir, "app/releases/4.0.0/release.json")).exists()).toBe(false);416+		expect(versions()).not.toContain("4.0.0");417+	});418+419+	test("two deletes at once leave latest naming a build that still exists", async () => {420+		await Promise.all([421+			deleteRelease(folder(), inst.kepr, mirror(), "app", "3.0.0"),422+			deleteRelease(folder(), inst.kepr, mirror(), "app", "2.0.0"),423+		]);424+		expect(await latest()).toBe("1.0.0");425+		expect(versions()).toEqual(["1.0.0"]);373426 	});374427 });375428 

view file