import { existsSync, renameSync } from "node:fs"; import { join } from "node:path"; import { type Bucket, inTurn, KEPR_PREFIX } from "../bucket/reader.ts"; import type { SyncTarget } from "../bucket/sync.ts"; import { closeMirror } from "../mirror/store.ts"; import { RESERVED_NAMES } from "../server/reserved.ts"; import { dropTarballs } from "../tarball/cache.ts"; import type { AdminContext } from "./console.ts"; import { findRepo, moveRepoRows } from "./store.ts"; // Lowercase, as Koh names a repo at its first offer (June, 2026-10-01). const NEW_NAME = /^[a-z0-9_-][a-z0-9._-]{0,99}$/; // Written once and never changed, so a rename that has to catch up copies them only once. const IMMUTABLE = /^[^/]+\/(objects|saves|events)\//; // Copies in flight at once: enough to keep a far bucket busy, few enough for a 2 GB host. const AT_ONCE = 8; /** Renames under way, old name to new, and each repo's last failed rename, for the console. */ export const renames = new Map(); export const renameErrors = new Map(); /** Why `from` can't be renamed to `to` now, or null. */ export async function renameProblem( ctx: AdminContext, from: string, to: string, ): Promise { if (!NEW_NAME.test(to)) return "a new name is lowercase letters, digits, '.', '_' and '-'."; if (RESERVED_NAMES.has(to)) return `"${to}" is one of Kepr's own addresses.`; if (renames.has(from)) return `it is already being renamed to ${renames.get(from)}.`; if (ctx.config.bucket.kind === "s3" && !ctx.config.bucket.write) { return "renaming writes to the bucket: add its write key in the bucket section."; } if (findRepo(ctx.kepr, to) || (await ctx.sync.bucket.list(`${to}/`)).length > 0) { return `"${to}" is taken.`; } return null; } /** * Renames a repo in the background (ยง30, 2026-10-01), with syncs held so none sees it half moved. * Every key is copied to the new name, `head` last, again until the old `head` stops moving; then * `.kepr/renamed/` tells Koh the new name, Kepr's rows, mirror and hidden paths move, and the * old keys go. A machine offering to the old name meanwhile loses nothing: its saves are its own, * and its next offer follows the marker. */ export function renameRepo(ctx: AdminContext, from: string, to: string): void { renames.set(from, to); renameErrors.delete(from); ctx.syncer .hold(() => move(ctx.sync, from, to)) .catch((error) => renameErrors.set(from, error instanceof Error ? error.message : `${error}`)) .finally(() => renames.delete(from)); } async function move({ bucket, kepr, mirrorDir }: SyncTarget, from: string, to: string) { const copied = new Set(); let head: string | null = null; for (let pass = 0; ; pass++) { const now = await text(bucket, `${from}/head`); if (now === null) throw new Error(`${from} has no head in the bucket.`); if (now === head) break; if (pass === 5) throw new Error(`${from} kept moving while it was copied; try again.`); const keys = (await bucket.list(`${from}/`)).filter( (key) => key !== `${from}/head` && !(IMMUTABLE.test(key) && copied.has(key)), ); await inTurn(keys, AT_ONCE, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`)); for (const key of keys) copied.add(key); await bucket.copy(`${from}/head`, `${to}/head`); head = now; } await bucket.put( `${KEPR_PREFIX}renamed/${from}`, new TextEncoder().encode(`${to}\n`), "text/plain", ); const hidden = await bucket.get(`${KEPR_PREFIX}hidden/${from}`); if (hidden) { await bucket.put(`${KEPR_PREFIX}hidden/${to}`, hidden, "text/plain"); await bucket.remove(`${KEPR_PREFIX}hidden/${from}`); } await dropTarballs(bucket, from); moveRepoRows(kepr, from, to); closeMirror(mirrorDir, from); for (const suffix of ["", "-wal", "-shm"]) { const path = join(mirrorDir, `${from}.db${suffix}`); if (existsSync(path)) renameSync(path, join(mirrorDir, `${to}.db${suffix}`)); } await inTurn(await bucket.list(`${from}/`), AT_ONCE, (key) => bucket.removeRenamed(key)); } async function text(bucket: Bucket, key: string): Promise { const bytes = await bucket.get(key); return bytes && new TextDecoder().decode(bytes).trim(); }