79cdb799725c

rename a repo from the console: copy its keys with head last, leave a marker for Koh, move Kepr's rows and mirror, delete the old keys and redirect old pages; tests pinned to koh3 3.2.27 (v3.0.190)

time
2026-10-02 02:05 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
rename repos
parent
9d511388989e
12 files package.json+1 −1src/admin/rename.ts+100 −0src/admin/repos.ts+30 −0src/admin/store.ts+23 −0src/bucket/reader.ts+35 −0src/bucket/sync.ts+14 −3src/mirror/store.ts+7 −0src/server/app.ts+13 −0src/server/db.ts+15 −0tests/bucket.test.ts+8 −0tests/koh-version.txt+1 −1tests/rename.test.ts+79 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.189",3+	"version": "3.0.190",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/rename.ts+100 −0
@@ -0,0 +1,100 @@1+import { existsSync, renameSync } from "node:fs";2+import { join } from "node:path";3+import { type Bucket, KEPR_PREFIX } from "../bucket/reader.ts";4+import { holdSync, type SyncTarget } from "../bucket/sync.ts";5+import { closeMirror } from "../mirror/store.ts";6+import { RESERVED_NAMES } from "../server/reserved.ts";7+import { dropTarballs } from "../tarball/cache.ts";8+import type { AdminContext } from "./console.ts";9+import { findRepo, moveRepoRows } from "./store.ts";10+11+// Lowercase, as Koh names a repo at its first offer (June, 2026-10-01).12+const NEW_NAME = /^[a-z0-9_-][a-z0-9._-]{0,99}$/;13+// Written once and never changed, so a rename that has to catch up copies them only once.14+const IMMUTABLE = /^[^/]+\/(objects|saves|events)\//;15+// Copies in flight at once: enough to keep a far bucket busy, few enough for a 2 GB host.16+const AT_ONCE = 8;17+18+/** Renames under way, old name to new, and each repo's last failed rename, for the console. */19+export const renames = new Map<string, string>();20+export const renameErrors = new Map<string, string>();21+22+/** Why `from` can't be renamed to `to` now, or null. */23+export async function renameProblem(24+	ctx: AdminContext,25+	from: string,26+	to: string,27+): Promise<string | null> {28+	if (!NEW_NAME.test(to)) return "a new name is lowercase letters, digits, '.', '_' and '-'.";29+	if (RESERVED_NAMES.has(to)) return `"${to}" is one of Kepr's own addresses.`;30+	if (renames.has(from)) return `it is already being renamed to ${renames.get(from)}.`;31+	if (ctx.config.bucket.kind === "s3" && !ctx.config.bucket.write) {32+		return "renaming writes to the bucket: add its write key in the bucket section.";33+	}34+	if (findRepo(ctx.kepr, to) || (await ctx.sync.bucket.list(`${to}/`)).length > 0) {35+		return `"${to}" is taken.`;36+	}37+	return null;38+}39+40+/**41+ * Renames a repo in the background (§30, 2026-10-01), with syncs held so none sees it half moved.42+ * Every key is copied to the new name, `head` last, again until the old `head` stops moving; then43+ * `.kepr/renamed/<old>` tells Koh the new name, Kepr's rows, mirror and hidden paths move, and the44+ * old keys go. A machine offering to the old name meanwhile loses nothing: its saves are its own,45+ * and its next offer follows the marker.46+ */47+export function renameRepo(sync: SyncTarget, from: string, to: string): void {48+	renames.set(from, to);49+	renameErrors.delete(from);50+	holdSync(() => move(sync, from, to))51+		.catch((error) => renameErrors.set(from, error instanceof Error ? error.message : `${error}`))52+		.finally(() => renames.delete(from));53+}54+55+async function move({ bucket, kepr, mirrorDir }: SyncTarget, from: string, to: string) {56+	const copied = new Set<string>();57+	let head: string | null = null;58+	for (let pass = 0; ; pass++) {59+		const now = await text(bucket, `${from}/head`);60+		if (now === null) throw new Error(`${from} has no head in the bucket.`);61+		if (now === head) break;62+		if (pass === 5) throw new Error(`${from} kept moving while it was copied; try again.`);63+		const keys = (await bucket.list(`${from}/`)).filter(64+			(key) => key !== `${from}/head` && !(IMMUTABLE.test(key) && copied.has(key)),65+		);66+		await inTurn(keys, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`));67+		for (const key of keys) copied.add(key);68+		await bucket.copy(`${from}/head`, `${to}/head`);69+		head = now;70+	}71+	await bucket.put(72+		`${KEPR_PREFIX}renamed/${from}`,73+		new TextEncoder().encode(`${to}\n`),74+		"text/plain",75+	);76+	const hidden = await bucket.get(`${KEPR_PREFIX}hidden/${from}`);77+	if (hidden) {78+		await bucket.put(`${KEPR_PREFIX}hidden/${to}`, hidden, "text/plain");79+		await bucket.remove(`${KEPR_PREFIX}hidden/${from}`);80+	}81+	await dropTarballs(bucket, from);82+	moveRepoRows(kepr, from, to);83+	closeMirror(mirrorDir, from);84+	for (const suffix of ["", "-wal", "-shm"]) {85+		const path = join(mirrorDir, `${from}.db${suffix}`);86+		if (existsSync(path)) renameSync(path, join(mirrorDir, `${to}.db${suffix}`));87+	}88+	await inTurn(await bucket.list(`${from}/`), (key) => bucket.removeRenamed(key));89+}90+91+async function text(bucket: Bucket, key: string): Promise<string | null> {92+	const bytes = await bucket.get(key);93+	return bytes && new TextDecoder().decode(bytes).trim();94+}95+96+async function inTurn(keys: string[], each: (key: string) => Promise<void>): Promise<void> {97+	for (let i = 0; i < keys.length; i += AT_ONCE) {98+		await Promise.all(keys.slice(i, i + AT_ONCE).map(each));99+	}100+}

view file

src/admin/repos.ts+30 −0
@@ -20,6 +20,7 @@2020 import { type RepoText, repoText, setRepoText } from "../server/db.ts";2121 import { dropTarballs } from "../tarball/cache.ts";2222 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";23+import { renameErrors, renameProblem, renameRepo, renames } from "./rename.ts";2324 import { type AdminRepo, allRepos, findRepo, type RepoEdit, updateRepo } from "./store.ts";2425 import { LIMITS } from "./text.ts";2526 @@ -124,6 +125,18 @@124125 		);125126 	});126127 128+	app.post("/admin/repos/:repo/rename", async (c) => {129+		const repo = findRepo(ctx.kepr, c.req.param("repo"));130+		if (!repo) return c.notFound();131+		const to = ((await textFields(c)).name ?? "").trim();132+		const problem = await renameProblem(ctx, repo.name, to);133+		if (problem) {134+			return c.html(reposPage(c, ctx, `${repo.name}: ${problem}`, repo.name, "settings"), 400);135+		}136+		renameRepo(ctx.sync, repo.name, to);137+		return c.redirect(`/admin/repos?repo=${repo.name}&tab=settings`, 303);138+	});139+127140 	app.post("/admin/repos/:repo/tarballs/delete", async (c) => {128141 		const repo = findRepo(ctx.kepr, c.req.param("repo"));129142 		if (!repo) return c.notFound();@@ -338,6 +351,23 @@338351 <p class="issue-meta mono">One path per line, such as notes.md or docs/private. Hidden from every page,339352 the stats and tarballs, and passed to Koh for .kohignore when there is a write key.</p>340353 <button class="issues-submit" type="submit">save ${repo.name}</button>354+</form>355+${renameForm(c, repo.name)}`;356+}357+358+// The rename runs in the background; the console shows it under way, or why it last failed.359+function renameForm(c: Context, name: string) {360+	const to = renames.get(name);361+	if (to)362+		return html`<p class="admin-intro">Renaming ${name} to ${to}. This page shows ${to} once it is done.</p>`;363+	return html`${notice(renameErrors.get(name) ? `rename failed: ${renameErrors.get(name)}` : "")}364+<form class="issues-form" method="post" action="/admin/repos/${name}/rename">365+${formField(c)}366+<label class="issues-label" for="${name}-rename">rename</label>367+<input class="issues-input mono" id="${name}-rename" name="name" maxlength="100" required>368+<p class="issue-meta mono">Moves its history to the new name in the bucket, with its settings, issues and369+counts, and sends old links there. Your machines follow it on their next save or steal.</p>370+<button class="issues-submit" type="submit">rename ${name}</button>341371 </form>`;342372 }343373 

view file

src/admin/store.ts+23 −0
@@ -41,6 +41,29 @@4141 	);4242 }4343 44+// Every kepr.db table that names a repo, and its column.45+const REPO_COLUMNS = [46+	["repos", "name"],47+	["repo_text", "repo"],48+	["release_settings", "repo"],49+	["downloads", "repo"],50+	["download_days", "repo"],51+	["installs", "repo"],52+	["issues", "repo"],53+	["blocked_reporters", "repo"],54+] as const;55+56+/** Moves a renamed repo's settings, texts, issues and counts to its new name, and records it. */57+export function moveRepoRows(db: Database, from: string, to: string): void {58+	db.transaction(() => {59+		for (const [table, column] of REPO_COLUMNS) {60+			db.query(`UPDATE ${table} SET ${column} = ? WHERE ${column} = ?`).run(to, from);61+		}62+		db.query("DELETE FROM renamed WHERE old = ?").run(to);63+		db.query("INSERT OR REPLACE INTO renamed (old, new) VALUES (?, ?)").run(from, to);64+	})();65+}66+4467 /** Writes site settings in one transaction; an empty value removes the key, so defaults apply. */4568 export function saveSettings(db: Database, values: Record<string, string>): void {4669 	db.transaction(() => {

view file

src/bucket/reader.ts+35 −0
@@ -27,6 +27,10 @@2727 	put(key: string, bytes: Uint8Array, type: string): Promise<void>;2828 	/** Deletes one of Kepr's own files, a release's file or `releases/latest`; nothing else. */2929 	remove(key: string): Promise<void>;30+	/** Copies a key of one repo to another repo: a rename (June, 2026-10-01), and nothing else. */31+	copy(from: string, to: string): Promise<void>;32+	/** Deletes a key of a repo that a rename has copied to its new name. */33+	removeRenamed(key: string): Promise<void>;3034 };3135 3236 export const KEPR_PREFIX = ".kepr/";@@ -39,6 +43,17 @@3943 const RELEASE_WRITE = /^[^/]+\/releases\/(latest|[^/]+\/release\.json)$/;4044 const RELEASE_FILE = /^[^/]+\/releases\/(latest|[^/]+\/.+)$/;4145 46+// A key inside a repo, as a rename moves them: never Kepr's own folder, never `..` or empty parts.47+function requireRepoKey(key: string): void {48+	if (49+		key.startsWith(KEPR_PREFIX) ||50+		!key.includes("/") ||51+		key.split("/").some((p) => p === ".." || p === "")52+	) {53+		throw new BucketWriteError(`a rename moves only a repo's keys, not "${key}"`);54+	}55+}56+4257 function requireWritable(key: string, releaseKey: RegExp): void {4358 	const allowed = key.startsWith(KEPR_PREFIX) || releaseKey.test(key);4459 	if (!allowed || key.split("/").some((part) => part === ".." || part === "")) {@@ -146,6 +161,17 @@146161 			requireWritable(key, RELEASE_FILE);147162 			await requireWriter().file(key).delete();148163 		},164+		async copy(from, to) {165+			requireRepoKey(from);166+			requireRepoKey(to);167+			const bytes = await this.get(from);168+			if (bytes === null) throw new Error(`${from} is missing`);169+			await requireWriter().file(to).write(bytes);170+		},171+		async removeRenamed(key) {172+			requireRepoKey(key);173+			await requireWriter().file(key).delete();174+		},149175 	};150176 }151177 @@ -183,6 +209,15 @@183209 			await unlink(join(root, key)).catch((error: NodeJS.ErrnoException) => {184210 				if (error.code !== "ENOENT") throw error;185211 			});212+		},213+		async copy(from, to) {214+			requireRepoKey(from);215+			requireRepoKey(to);216+			await Bun.write(join(root, to), Bun.file(join(root, from)));217+		},218+		async removeRenamed(key) {219+			requireRepoKey(key);220+			await unlink(join(root, key));186221 		},187222 	};188223 }

view file

src/bucket/sync.ts+14 −3
@@ -52,10 +52,21 @@5252 5353 /** Syncs every repo in the bucket; the admin console's "sync now" calls this too. Never overlaps itself. */5454 export function syncAll(target: SyncTarget): Promise<void> {55-	running ??= syncEach(target).finally(() => {56-		running = null;55+	return running ?? holdSync(() => syncEach(target));56+}57+58+/**59+ * Runs `work` once any sync under way has finished, and keeps syncs waiting until it is done: a60+ * rename moves a repo's keys, which a sync must never see half moved.61+ */62+export function holdSync(work: () => Promise<void>): Promise<void> {63+	// Cleared before anyone awaiting `work` resumes, so a sync asked for next starts afresh.64+	const mine = (running ?? Promise.resolve()).then(work).finally(() => {65+		if (running === slot) running = null;5766 	});58-	return running;67+	const slot = mine.catch(() => {});68+	running = slot;69+	return mine;5970 }6071 6172 /** Rebuilds one repo's mirror from the bucket, once any sync under way has finished. */

view file

src/mirror/store.ts+7 −0
@@ -116,6 +116,13 @@116116 117117 const open = new Map<string, Database>();118118 119+/** Closes a repo's mirror, so a rename can move its file. */120+export function closeMirror(dir: string, repo: string): void {121+	const path = join(dir, `${repo}.db`);122+	open.get(path)?.close();123+	open.delete(path);124+}125+119126 /** The mirror of one repo at `<dir>/<repo>.db`, created on first use and kept open. */120127 export function openMirror(dir: string, repo: string): Database {121128 	const path = join(dir, `${repo}.db`);

view file

src/server/app.ts+13 −0
@@ -33,6 +33,7 @@3333 import { registerTarballRoutes } from "../tarball/archive.ts";3434 import { CONFIG_FILE, type Config } from "./config.ts";3535 import { connectKeys, noCode, registerConnect } from "./connect.ts";36+import { renamedTo } from "./db.ts";3637 import { registerLogin } from "./login.ts";3738 3839 const MAX_BODY_BYTES = 64 * 1024;@@ -90,6 +91,18 @@9091 		}9192 	});9293 	app.use(securityHeaders(imgSrc));94+	// A renamed repo's old pages, which now 404, move to its new name (June, 2026-10-01). Koh's95+	// routes keep their 404, so a machine follows the bucket's rename marker instead.96+	app.use("*", async (c, next) => {97+		await next();98+		if (c.req.method !== "GET" || c.res.status !== 404) return;99+		const [, first = "", second, ...rest] = c.req.path.split("/");100+		const to = second !== "koh" && renamedTo(kepr, first);101+		if (!to) return;102+		const query = new URL(c.req.url).search;103+		const path = ["", to, ...(second === undefined ? [] : [second, ...rest])].join("/");104+		c.res = c.redirect(`${path}${query}`, 301);105+	});93106 	const limit = (maxSize: number) =>94107 		bodyLimit({95108 			maxSize,

view file

src/server/db.ts+15 −0
@@ -127,6 +127,11 @@127127 	created_at INTEGER NOT NULL,128128 	sent_at    INTEGER129129 );130+-- A repo renamed from the console (June, 2026-10-01): its old pages redirect to the new name.131+CREATE TABLE IF NOT EXISTS renamed (132+	old TEXT PRIMARY KEY,133+	new TEXT NOT NULL134+);130135 `;131136 132137 /** What raises a notification (§24); each can be switched off. */@@ -205,6 +210,16 @@205210 		.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?")206211 		.get(key);207212 	return row?.value ?? null;213+}214+215+/** The name a repo was renamed to, following later renames, or null. */216+export function renamedTo(db: Database, name: string): string | null {217+	const query = db.query<{ new: string }, [string]>("SELECT new FROM renamed WHERE old = ?");218+	let to: string | null = null;219+	for (let row = query.get(name), hops = 0; row && hops < 5; row = query.get(row.new), hops++) {220+		to = row.new;221+	}222+	return to;208223 }209224 210225 /** Every repo starts private (§20); registering a known repo changes nothing. */

view file

tests/bucket.test.ts+8 −0
@@ -157,6 +157,8 @@157157 				viewUrl: () => null,158158 				put: async () => {},159159 				remove: async () => {},160+				copy: async () => {},161+				removeRenamed: async () => {},160162 			},161163 			fx.repo,162164 			id,@@ -210,6 +212,8 @@210212 				viewUrl: () => null,211213 				put: async () => {},212214 				remove: async () => {},215+				copy: async () => {},216+				removeRenamed: async () => {},213217 			},214218 			fx.repo,215219 			id,@@ -289,6 +293,8 @@289293 			viewUrl: () => null,290294 			put: async () => {},291295 			remove: async () => {},296+			copy: async () => {},297+			removeRenamed: async () => {},292298 		};293299 		expect((await readEvent(reader, fx.repo, event.id)).kind).toBe("strip");294300 		for (const change of [@@ -310,6 +316,8 @@310316 				viewUrl: () => null,311317 				put: async () => {},312318 				remove: async () => {},319+				copy: async () => {},320+				removeRenamed: async () => {},313321 			};314322 			await expect(readEvent(tampered, fx.repo, event.id)).rejects.toThrow("Koh 3 schema");315323 		}

view file

tests/koh-version.txt+1 −1
@@ -1 +1 @@1-3.2.261+3.2.27

view file

tests/rename.test.ts+79 −0
@@ -0,0 +1,79 @@1+// Covers renaming a repo from the console (June, 2026-10-01): the history moves to the new name in2+// the bucket with a marker left behind, Kepr's own rows and counts move with it, old page URLs3+// redirect, and Koh follows the marker on its next offer.4+5+import { beforeAll, describe, expect, test } from "bun:test";6+import { writeFile } from "node:fs/promises";7+import { join } from "node:path";8+import { Browser, type Instance, instance } from "./browser.ts";9+import { makeRepos } from "./fixture.ts";10+11+describe("renaming a repo", () => {12+	let inst: Instance;13+	let browser: Browser;14+	let repos: Awaited<ReturnType<typeof makeRepos>>;15+	const rename = (name: string) => browser.post("/admin/repos/app/rename", { name });16+	const keys = (prefix: string) => [...new Bun.Glob(`${prefix}/**`).scanSync(repos.bucketDir)];17+	const bucketText = (key: string) => Bun.file(join(repos.bucketDir, key)).text();18+	// The rename runs after its response; wait for the new name to answer.19+	const settled = async (path: string) => {20+		for (let i = 0; i < 100 && (await inst.app.request(path)).status !== 200; i++) {21+			await Bun.sleep(50);22+		}23+	};24+25+	beforeAll(async () => {26+		repos = await makeRepos(["app", "other"]);27+		inst = await instance(repos.bucketDir);28+		browser = new Browser(inst.app);29+		await browser.signIn();30+		await browser.post("/admin/repos/app", { public: "1", category: "tools", hidden_paths: "" });31+		inst.kepr.run(32+			"INSERT INTO downloads (repo, version, platform, count) VALUES ('app', '1.0.0', 'linux-x64', 7)",33+		);34+	});35+36+	test("refuses a name that is malformed, uppercase, taken or Kepr's own", async () => {37+		for (const name of ["", "a/b", "..", "Tool", "other", "admin", "app"]) {38+			expect(`${name} ${(await rename(name)).status}`).toBe(`${name} 400`);39+		}40+		expect(keys("app").length).toBeGreaterThan(0);41+	});42+43+	test("moves the history, Kepr's settings and counts, and leaves a marker for Koh", async () => {44+		const head = await bucketText("app/head");45+		expect((await rename("tool")).status).toBe(303);46+		await settled("/tool");47+		expect(await bucketText("tool/head")).toBe(head);48+		expect(keys("app")).toEqual([]);49+		expect(await bucketText(".kepr/renamed/app")).toBe("tool\n");50+		const row = inst.kepr.query("SELECT public, category FROM repos WHERE name = 'tool'").get();51+		expect(row).toEqual({ public: 1, category: "tools" });52+		expect(inst.kepr.query("SELECT COUNT(*) AS n FROM repos WHERE name = 'app'").get()).toEqual({53+			n: 0,54+		});55+		const counted = inst.kepr.query("SELECT count FROM downloads WHERE repo = 'tool'").get();56+		expect(counted).toEqual({ count: 7 });57+	});58+59+	test("old page URLs redirect to the new name; Koh's routes do not", async () => {60+		for (const [from, to] of [61+			["/app", "/tool"],62+			["/app/files/README.md?at=abc123", "/tool/files/README.md?at=abc123"],63+		]) {64+			const response = await inst.app.request(from as string);65+			expect(`${from} ${response.status} ${response.headers.get("location")}`).toBe(66+				`${from} 301 ${to}`,67+			);68+		}69+		expect((await inst.app.request("/app/koh/head")).status).toBe(404);70+	});71+72+	test("Koh follows the marker on its next offer", async () => {73+		await writeFile(join(repos.bucketDir, "..", "work", "app", "later.txt"), "after\n");74+		repos.koh("app", "save", "after the rename");75+		const local = JSON.parse(repos.koh("app", "view", "--json")).head_id as string;76+		expect(await bucketText("tool/head")).toBe(local);77+		expect(keys("app")).toEqual([]);78+	});79+});

view file