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
↓ kepr-79cdb799.tar.gzatlas at this save
files changed12 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 −0package.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": {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+}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 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(() => {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 }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. */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`);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,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. */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 }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+});