70fe79feb961

delete a repo from the console, bucket and all, behind a tick and its typed name (v3.2.1)

time
2026-10-08 21:46 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
deepseek-flash
session
repo delete
parent
556a0d535096
9 files package.json+1 −1scripts/budget.ts+4 −4src/admin/repos.ts+54 −0src/bucket/reader.ts+59 −0src/bucket/sync.ts+3 −0src/server/remove.ts+92 −0tests/bucket.test.ts+8 −0tests/fixture.ts+6 −3tests/repodelete.test.ts+109 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.2.0",3+	"version": "3.2.1",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

scripts/budget.ts+4 −4
@@ -1,10 +1,10 @@11 // §26: fails when any area passes its budget or the TypeScript total passes the2-// 13,400-line ceiling.3-const CEILING = 13_400;2+// 13,480-line ceiling.3+const CEILING = 13_480;44 55 // Prefixes are matched in order, so the specific files come before the directories.66 const TS_AREAS: [string, string[], number][] = [7-	["bucket", ["src/bucket/"], 1170],7+	["bucket", ["src/bucket/"], 1220],88 	["mirror", ["src/mirror/"], 1100],99 	["describe", ["src/describe/"], 530],1010 	["pages", ["src/pages/"], 2600],@@ -13,7 +13,7 @@1313 	["issues", ["src/issues/"], 1700],1414 	["admin", ["src/admin/"], 2800],1515 	["import-kepr2", ["scripts/import-kepr2.ts"], 300],16-	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 2000],16+	["server", ["src/server/", "src/main.ts", "scripts/budget.ts"], 2030],1717 ];1818 const ASSET_AREAS: [string, string, number][] = [1919 	["public/js", "public/js/**/*.js", 1400],

view file

src/admin/repos.ts+54 −0
@@ -17,6 +17,7 @@1717 	visibleReleases,1818 } from "../releases/releases.ts";1919 import { type RepoText, repoText, setRepoText } from "../server/db.ts";20+import { type Removal, removal, startRemoval } from "../server/remove.ts";2021 import { dropTarballs } from "../tarball/cache.ts";2122 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";2223 import { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts";@@ -122,6 +123,26 @@122123 		);123124 	});124125 126+	app.post("/admin/repos/:repo/delete", async (c) => {127+		const repo = findRepo(ctx.kepr, c.req.param("repo"));128+		if (!repo) return c.notFound();129+		const fields = await textFields(c);130+		const typed = (fields.name ?? "").trim();131+		if (fields.delete !== "on" || typed !== repo.name) {132+			return c.html(133+				reposPage(c, ctx, `Tick the box and type ${repo.name} to delete it.`, repo.name),134+				400,135+			);136+		}137+		if (!startRemoval(ctx.sync, repo.name)) {138+			return c.html(139+				reposPage(c, ctx, "A repo is already being deleted. Let it finish first.", repo.name),140+				400,141+			);142+		}143+		return c.redirect(`/admin/repos?repo=${encodeURIComponent(repo.name)}`, 303);144+	});145+125146 	app.post("/admin/repos/:repo/rename", async (c) => {126147 		const repo = findRepo(ctx.kepr, c.req.param("repo"));127148 		if (!repo) return c.notFound();@@ -276,7 +297,40 @@276297 ${section("settings", settingsSection(c, ctx, repo))}277298 ${section("builds", buildsSection(c, ctx, repo))}278299 ${section("archives", archivesSection(c, repo))}300+${section("delete", deleteSection(c, ctx, repo))}279301 </article>`;302+}303+304+/**305+ * §24: deleting a repo is the one thing Kepr writes into Koh's tree for, so it takes a tick and the306+ * name typed out. The page follows the delete while it runs; nothing polls, so it says to reload.307+ */308+function deleteSection(c: Context, ctx: AdminContext, repo: AdminRepo) {309+	const under = removal(ctx.kepr);310+	const state =311+		under && under.repo === repo.name312+			? html`<p class="issues-error mono" role="status">${deleteProgress(under)}</p>`313+			: "";314+	return html`${state}315+<form class="issues-form" method="post" action="/admin/repos/${repo.name}/delete">316+${formField(c)}317+<p>This deletes every key of <span class="mono">${repo.name}</span> in the bucket: its history,318+releases, issues and counts. Then it drops Kepr's own copy of it. No other repo is touched, and319+nothing here can undo it.</p>320+<label class="issues-label">321+<input type="checkbox" name="delete" value="on" required> delete ${repo.name} from the bucket</label>322+<label class="issues-label" for="${repo.name}-delete-name">type ${repo.name} to confirm</label>323+<input class="issues-input mono" id="${repo.name}-delete-name" name="name" autocomplete="off" required>324+<button class="issues-submit" type="submit">delete ${repo.name}</button>325+</form>`;326+}327+328+function deleteProgress(under: Removal): string {329+	const gone = `${String(under.done)} of ${String(under.total)} keys`;330+	if (under.error !== "") {331+		return `deleting ${under.repo} stopped after ${gone}: ${under.error}. Delete it again to finish the rest.`;332+	}333+	return `deleting ${under.repo}: ${gone} gone. Reload to follow it.`;280334 }281335 282336 function overviewSection(ctx: AdminContext, repo: AdminRepo) {

view file

src/bucket/reader.ts+59 −0
@@ -35,6 +35,13 @@3535 	copy(from: string, to: string): Promise<void>;3636 	/** Deletes a key of a repo that a rename has copied to its new name. */3737 	removeRenamed(key: string): Promise<void>;38+	/**39+	 * Every key that belongs to one repo (§24): its own, and Kepr's files for it under `.kepr/`.40+	 * What deleting a repo has to remove, and nothing of any other repo's.41+	 */42+	repoKeys(repo: string): Promise<string[]>;43+	/** Deletes one of those keys; anything not of that repo is refused. */44+	removeRepoKey(repo: string, key: string): Promise<void>;3845 };3946 4047 export const KEPR_PREFIX = ".kepr/";@@ -78,6 +85,38 @@7885 	}7986 }8087 88+// Kepr's own files for one repo: uploaded media, kept tarballs, the hidden list, the rename record.89+const keprRepoFolders = (repo: string) => [90+	`${KEPR_PREFIX}media/${repo}/`,91+	`${KEPR_PREFIX}tarballs/${repo}/`,92+];93+const keprRepoFiles = (repo: string) => [94+	`${KEPR_PREFIX}hidden/${repo}`,95+	`${KEPR_PREFIX}renamed/${repo}`,96+];97+98+/**99+ * Whether `key` belongs to `repo` alone: inside it, or one of the files Kepr keeps for it. An empty100+ * name is nothing, and no key may escape through `..` or an empty part.101+ */102+export function isRepoKey(repo: string, key: string): boolean {103+	if (repo === "" || key.split("/").some((part) => part === ".." || part === "")) return false;104+	if (key.startsWith(`${repo}/`)) return true;105+	if (keprRepoFolders(repo).some((folder) => key.startsWith(folder))) return true;106+	return keprRepoFiles(repo).includes(key);107+}108+109+/** Every key of one repo: what a bucket lists under it, plus the files Kepr keeps for it. */110+async function listRepoKeys(111+	list: (prefix: string) => Promise<string[]>,112+	repo: string,113+): Promise<string[]> {114+	if (repo === "") return [];115+	const keys = await list(`${repo}/`);116+	for (const folder of keprRepoFolders(repo)) keys.push(...(await list(folder)));117+	return [...keys, ...keprRepoFiles(repo)].filter((key) => isRepoKey(repo, key));118+}119+81120 export function openBucket(source: BucketSource): Bucket {82121 	return source.kind === "s3" ? s3Reader(source) : folderReader(source.path);83122 }@@ -196,6 +235,15 @@196235 			requireRepoKey(key);197236 			await write(key, (file) => file.delete());198237 		},238+		async repoKeys(repo) {239+			return listRepoKeys((prefix) => this.list(prefix), repo);240+		},241+		async removeRepoKey(repo, key) {242+			if (!isRepoKey(repo, key)) {243+				throw new BucketWriteError(`Kepr deletes only ${repo}'s own keys, not "${key}"`);244+			}245+			await write(key, (file) => file.delete());246+		},199247 	};200248 }201249 @@ -253,6 +301,17 @@253301 		async removeRenamed(key) {254302 			requireRepoKey(key);255303 			await unlink(join(root, key));304+		},305+		async repoKeys(repo) {306+			return listRepoKeys(list, repo);307+		},308+		async removeRepoKey(repo, key) {309+			if (!isRepoKey(repo, key)) {310+				throw new BucketWriteError(`Kepr deletes only ${repo}'s own keys, not "${key}"`);311+			}312+			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {313+				if (error.code !== "ENOENT") throw error;314+			});256315 		},257316 	};258317 }

view file

src/bucket/sync.ts+3 −0
@@ -22,6 +22,7 @@2222 	registerRepo,2323 	renamedTo,2424 } from "../server/db.ts";25+import { removal } from "../server/remove.ts";2526 import { RESERVED_NAMES } from "../server/reserved.ts";2627 import { dropTarballs } from "../tarball/cache.ts";2728 import { type Bucket, inTurn, KEPR_PREFIX, openBucket } from "./reader.ts";@@ -187,6 +188,8 @@187188 	keepState(target.kepr, "bucket_read_at", String(Math.floor(Date.now() / 1000)));188189 	keepState(target.kepr, "bucket_repos", JSON.stringify(repos));189190 	for (const repo of repos) {191+		// A repo being deleted is left alone: landing its records mid-delete would put its row back.192+		if (repo === removal(target.kepr)?.repo) continue;190193 		registerRepo(target.kepr, repo);191194 		let error: string | null = null;192195 		if (RESERVED_NAMES.has(repo)) {

view file

src/server/remove.ts+92 −0
@@ -0,0 +1,92 @@1+import type { Database } from "bun:sqlite";2+import { rmSync } from "node:fs";3+import { join } from "node:path";4+import { inTurn } from "../bucket/reader.ts";5+import type { SyncTarget } from "../bucket/sync.ts";6+import { closeMirror } from "../mirror/store.ts";7+import { keepState, setting } from "./db.ts";8+9+// One repo delete at a time. Its progress lives in settings rather than in this process, so the10+// console can show it and a Kepr restarted mid-delete can be asked to finish the job.11+let running: Promise<void> | null = null;12+13+export type Removal = { repo: string; done: number; total: number; error: string };14+15+/** The delete in progress, or null. */16+export function removal(kepr: Database): Removal | null {17+	const repo = setting(kepr, "removing_repo");18+	if (repo === null) return null;19+	return {20+		repo,21+		done: Number(setting(kepr, "removing_done") ?? 0),22+		total: Number(setting(kepr, "removing_total") ?? 0),23+		error: setting(kepr, "removing_error") ?? "",24+	};25+}26+27+/** Starts deleting a whole repo (§24); false when one is already being deleted. */28+export function startRemoval(target: SyncTarget, repo: string): boolean {29+	if (running) return false;30+	running = deleteRepo(target, repo).finally(() => {31+		running = null;32+	});33+	return true;34+}35+36+/** The delete under way, or null when none is: what a caller waits on to see it finish. */37+export function removalRun(): Promise<void> | null {38+	return running;39+}40+41+async function deleteRepo(target: SyncTarget, repo: string): Promise<void> {42+	const { bucket, kepr, mirrorDir } = target;43+	keepState(kepr, "removing_repo", repo);44+	keepState(kepr, "removing_error", null);45+	try {46+		const keys = await bucket.repoKeys(repo);47+		keepState(kepr, "removing_total", String(keys.length));48+		keepState(kepr, "removing_done", "0");49+		let done = 0;50+		// A few keys at a time: a repo holds thousands, and one request each could take an hour.51+		await inTurn(keys, 4, async (key) => {52+			await bucket.removeRepoKey(repo, key);53+			done += 1;54+			if (done % 20 === 0) keepState(kepr, "removing_done", String(done));55+		});56+		keepState(kepr, "removing_done", String(done));57+		forgetRepo(kepr, mirrorDir, repo);58+		keepState(kepr, "removing_repo", null);59+	} catch (error) {60+		// The keys already gone stay gone; starting again lists what is left and finishes the job.61+		keepState(kepr, "removing_error", error instanceof Error ? error.message : String(error));62+	}63+}64+65+/** Everything Kepr holds for a repo: its mirror, and its rows. Its bucket keys are already gone. */66+export function forgetRepo(kepr: Database, mirrorDir: string, repo: string): void {67+	closeMirror(mirrorDir, repo);68+	for (const suffix of [".db", ".db-wal", ".db-shm"]) {69+		rmSync(join(mirrorDir, `${repo}${suffix}`), { force: true });70+	}71+	const children = ["issue_entries", "issue_files", "issue_followers"];72+	// Every table with a `repo` column, plus the rename record that spells both names.73+	const own = [74+		"blocked_reporters",75+		"issues",76+		"release_settings",77+		"downloads",78+		"download_days",79+		"installs",80+		"repo_text",81+	];82+	kepr.transaction(() => {83+		for (const table of children) {84+			kepr85+				.query(`DELETE FROM ${table} WHERE issue_id IN (SELECT id FROM issues WHERE repo = ?)`)86+				.run(repo);87+		}88+		for (const table of own) kepr.query(`DELETE FROM ${table} WHERE repo = ?`).run(repo);89+		kepr.query("DELETE FROM renamed WHERE old = ? OR new = ?").run(repo, repo);90+		kepr.query("DELETE FROM repos WHERE name = ?").run(repo);91+	})();92+}

view file

tests/bucket.test.ts+8 −0
@@ -173,6 +173,8 @@173173 				remove: async () => {},174174 				copy: async () => {},175175 				removeRenamed: async () => {},176+				repoKeys: async () => [],177+				removeRepoKey: async () => {},176178 			},177179 			fx.repo,178180 			id,@@ -230,6 +232,8 @@230232 				remove: async () => {},231233 				copy: async () => {},232234 				removeRenamed: async () => {},235+				repoKeys: async () => [],236+				removeRepoKey: async () => {},233237 			},234238 			fx.repo,235239 			id,@@ -313,6 +317,8 @@313317 			remove: async () => {},314318 			copy: async () => {},315319 			removeRenamed: async () => {},320+			repoKeys: async () => [],321+			removeRepoKey: async () => {},316322 		};317323 		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");318324 		for (const change of [@@ -338,6 +344,8 @@338344 				remove: async () => {},339345 				copy: async () => {},340346 				removeRenamed: async () => {},347+				repoKeys: async () => [],348+				removeRepoKey: async () => {},341349 			};342350 			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");343351 		}

view file

tests/fixture.ts+6 −3
@@ -137,13 +137,16 @@137137 	if (code !== 0) throw new Error(`koh strip ${path} failed: ${output}`);138138 }139139 140-/** Repos with one koh save each, all offered to one folder remote: enough history for releases. */141-export async function makeRepos(names: string[]): Promise<{140+/** What `makeRepos` hands back: the folder remote, its saves, and a way to run koh in a repo. */141+export type FixtureRepos = {142142 	bucketDir: string;143143 	saves: Record<string, string>;144144 	/** Runs koh in one of the repos, under the fixture's HOME. */145145 	koh(repo: string, ...args: string[]): string;146-}> {146+};147+148+/** Repos with one koh save each, all offered to one folder remote: enough history for releases. */149+export async function makeRepos(names: string[]): Promise<FixtureRepos> {147150 	const root = await mkdtemp(join(tmpdir(), "kepr-repos-"));148151 	const home = join(root, "home");149152 	const bucketDir = join(root, "bucket");

view file

tests/repodelete.test.ts+109 −0
@@ -0,0 +1,109 @@1+// Covers deleting a repo from the console (§24): the double confirmation, every key of that repo2+// gone from the bucket with no other repo touched, and Kepr's own rows and mirror dropped with it.3+4+import { beforeAll, describe, expect, test } from "bun:test";5+import { existsSync } from "node:fs";6+import { join } from "node:path";7+import { isRepoKey, openBucket } from "../src/bucket/reader.ts";8+import { removalRun } from "../src/server/remove.ts";9+import { Browser, type Instance, instance } from "./browser.ts";10+import { type FixtureRepos, makeRepos } from "./fixture.ts";11+12+describe("deleting a repo", () => {13+	let inst: Instance;14+	let browser: Browser;15+	let repos: FixtureRepos;16+	const keys = (prefix: string) => [...new Bun.Glob(`${prefix}/**`).scanSync(repos.bucketDir)];17+	const count = (sql: string) => (inst.kepr.query<{ n: number }, []>(sql).get() ?? { n: 0 }).n;18+	const remove = (fields: Record<string, string>) =>19+		browser.post("/admin/repos/app/delete", fields);20+	// The delete answers before it is done; startRemoval leaves the run to wait on.21+	const settled = () => removalRun() ?? Promise.resolve();22+23+	beforeAll(async () => {24+		repos = await makeRepos(["app", "other"]);25+		inst = await instance(repos.bucketDir);26+		browser = new Browser(inst.app);27+		await browser.signIn();28+		await browser.post("/admin/repos/app", { public: "1", category: "tools", hidden_paths: "" });29+		inst.kepr.run(30+			"INSERT INTO downloads (repo, version, platform, count) VALUES ('app', '1.0.0', 'linux-x64', 7)",31+		);32+		inst.kepr.run(33+			`INSERT INTO issues (repo, number, title, body, type, filed_at)34+VALUES ('app', 1, 'Crash', 'It broke.', 'bug', 0)`,35+		);36+		const issue = inst.kepr.query<{ id: number }, []>("SELECT id FROM issues").get()?.id ?? 0;37+		inst.kepr.run(38+			"INSERT INTO issue_entries (issue_id, author, body, created_at) VALUES (?, 'reporter', 'x', 0)",39+			[issue],40+		);41+		inst.kepr.run(42+			"INSERT INTO issue_followers (issue_id, email, created_at) VALUES (?, '[email protected]', 0)",43+			[issue],44+		);45+		// Kepr's own files for the repo, which a delete has to take with it.46+		await Bun.write(join(repos.bucketDir, ".kepr/hidden/app"), "notes.md\n");47+		await Bun.write(join(repos.bucketDir, ".kepr/media/app/abc.png"), "png");48+		await Bun.write(join(repos.bucketDir, ".kepr/tarballs/app/1/abcdef.tar.gz"), "tar");49+	});50+51+	test("takes both the tick and the name before anything is deleted", async () => {52+		expect((await remove({})).status).toBe(400);53+		expect((await remove({ delete: "on" })).status).toBe(400);54+		expect((await remove({ delete: "on", name: "other" })).status).toBe(400);55+		expect((await remove({ name: "app" })).status).toBe(400);56+		expect(keys("app").length).toBeGreaterThan(0);57+		expect(count("SELECT COUNT(*) AS n FROM repos WHERE name = 'app'")).toBe(1);58+	});59+60+	test("deletes only that repo's keys, its mirror and its rows", async () => {61+		const other = keys("other").length;62+		expect((await remove({ delete: "on", name: "app" })).status).toBe(303);63+		await settled();64+		expect(keys("app")).toEqual([]);65+		// Kepr's own files for the repo go too; a folder remote may leave the empty folders behind,66+		// which an S3 bucket never has and which no listing shows.67+		expect(existsSync(join(repos.bucketDir, ".kepr/hidden/app"))).toBe(false);68+		expect(keys(".kepr/media/app")).toEqual([]);69+		expect(keys(".kepr/tarballs/app")).toEqual([]);70+		// The other repo is untouched, keys and rows alike.71+		expect(keys("other").length).toBe(other);72+		expect(count("SELECT COUNT(*) AS n FROM repos WHERE name = 'other'")).toBe(1);73+		// Kepr's own copy: the mirror, and every row that pointed at the repo.74+		expect(existsSync(join(inst.dataDir, "mirror", "app.db"))).toBe(false);75+		for (const sql of [76+			"SELECT COUNT(*) AS n FROM repos WHERE name = 'app'",77+			"SELECT COUNT(*) AS n FROM downloads WHERE repo = 'app'",78+			"SELECT COUNT(*) AS n FROM issues WHERE repo = 'app'",79+			"SELECT COUNT(*) AS n FROM issue_entries",80+			"SELECT COUNT(*) AS n FROM issue_followers",81+		]) {82+			expect(`${sql} ${count(sql)}`).toBe(`${sql} 0`);83+		}84+	});85+86+	test("the delete path refuses any key that is not of that repo", async () => {87+		const bucket = openBucket(inst.config.bucket);88+		for (const key of [89+			"other/head",90+			"other/saves/abcdef",91+			".kepr/media/other/x.png",92+			"../app/head",93+		]) {94+			expect(`${key} ${isRepoKey("app", key)}`).toBe(`${key} false`);95+			await expect(bucket.removeRepoKey("app", key)).rejects.toThrow();96+		}97+		expect(isRepoKey("app", "app/head")).toBe(true);98+		expect(isRepoKey("app", ".kepr/tarballs/app/1/abcdef.tar.gz")).toBe(true);99+		expect(isRepoKey("app", ".kepr/hidden/app")).toBe(true);100+		expect(isRepoKey("", "head")).toBe(false);101+	});102+103+	test("the console offers the delete with both confirmations", async () => {104+		const page = await (await browser.get("/admin/repos?repo=other")).text();105+		expect(page).toContain('action="/admin/repos/other/delete"');106+		expect(page).toContain("type other to confirm");107+		expect(page).toContain("nothing here can undo it");108+	});109+});

view file