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
↓ kepr-70fe79fe.tar.gzatlas at this save
files changed9 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 −0package.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": {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],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) {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 }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)) {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+}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 }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");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+});