rename.ts

3.9 KB · raw

1import { existsSync, renameSync } from "node:fs";2import { join } from "node:path";3import { type Bucket, inTurn, KEPR_PREFIX } from "../bucket/reader.ts";4import type { SyncTarget } from "../bucket/sync.ts";5import { closeMirror } from "../mirror/store.ts";6import { RESERVED_NAMES } from "../server/reserved.ts";7import { dropTarballs } from "../tarball/cache.ts";8import type { AdminContext } from "./console.ts";9import { findRepo, moveRepoRows } from "./store.ts";1011// Lowercase, as Koh names a repo at its first offer (June, 2026-10-01).12const 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.14const IMMUTABLE = /^[^/]+\/(objects|saves|events)\//;15// Copies in flight at once: enough to keep a far bucket busy, few enough for a 2 GB host.16const AT_ONCE = 8;1718/** Renames under way, old name to new, and each repo's last failed rename, for the console. */19export const renames = new Map<string, string>();20export const renameErrors = new Map<string, string>();2122/** Why `from` can't be renamed to `to` now, or null. */23export 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 (findRepo(ctx.kepr, to) || (await ctx.sync.bucket.list(`${to}/`)).length > 0) {32		return `"${to}" is taken.`;33	}34	return null;35}3637/**38 * Renames a repo in the background (§30, 2026-10-01), with syncs held so none sees it half moved.39 * Every key is copied to the new name, `head` last, again until the old `head` stops moving; then40 * `.kepr/renamed/<old>` tells Koh the new name, Kepr's rows, mirror and hidden paths move, and the41 * old keys go. A machine offering to the old name meanwhile loses nothing: its saves are its own,42 * and its next offer follows the marker.43 */44export function renameRepo(ctx: AdminContext, from: string, to: string): void {45	renames.set(from, to);46	renameErrors.delete(from);47	ctx.syncer48		.hold(() => move(ctx.sync, from, to))49		.catch((error) => renameErrors.set(from, error instanceof Error ? error.message : `${error}`))50		.finally(() => renames.delete(from));51}5253async function move({ bucket, kepr, mirrorDir }: SyncTarget, from: string, to: string) {54	const copied = new Set<string>();55	let head: string | null = null;56	for (let pass = 0; ; pass++) {57		const now = await text(bucket, `${from}/head`);58		if (now === null) throw new Error(`${from} has no head in the bucket.`);59		if (now === head) break;60		if (pass === 5) throw new Error(`${from} kept moving while it was copied; try again.`);61		const keys = (await bucket.list(`${from}/`)).filter(62			(key) => key !== `${from}/head` && !(IMMUTABLE.test(key) && copied.has(key)),63		);64		await inTurn(keys, AT_ONCE, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`));65		for (const key of keys) copied.add(key);66		await bucket.copy(`${from}/head`, `${to}/head`);67		head = now;68	}69	await bucket.put(70		`${KEPR_PREFIX}renamed/${from}`,71		new TextEncoder().encode(`${to}\n`),72		"text/plain",73	);74	const hidden = await bucket.get(`${KEPR_PREFIX}hidden/${from}`);75	if (hidden) {76		await bucket.put(`${KEPR_PREFIX}hidden/${to}`, hidden, "text/plain");77		await bucket.remove(`${KEPR_PREFIX}hidden/${from}`);78	}79	await dropTarballs(bucket, from);80	moveRepoRows(kepr, from, to);81	closeMirror(mirrorDir, from);82	for (const suffix of ["", "-wal", "-shm"]) {83		const path = join(mirrorDir, `${from}.db${suffix}`);84		if (existsSync(path)) renameSync(path, join(mirrorDir, `${to}.db${suffix}`));85	}86	await inTurn(await bucket.list(`${from}/`), AT_ONCE, (key) => bucket.removeRenamed(key));87}8889async function text(bucket: Bucket, key: string): Promise<string | null> {90	const bytes = await bucket.get(key);91	return bytes && new TextDecoder().decode(bytes).trim();92}