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
↓ kepr-ba443d07.tar.gzatlas at this save
files changed3 files
package.json+1 −1src/releases/releases.ts+30 −11tests/console.test.ts+53 −0package.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": {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"]) {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