rename.ts

4.1 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 (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}3940/**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 */47export function renameRepo(ctx: AdminContext, from: string, to: string): void {48	renames.set(from, to);49	renameErrors.delete(from);50	ctx.syncer51		.hold(() => move(ctx.sync, from, to))52		.catch((error) => renameErrors.set(from, error instanceof Error ? error.message : `${error}`))53		.finally(() => renames.delete(from));54}5556async function move({ bucket, kepr, mirrorDir }: SyncTarget, from: string, to: string) {57	const copied = new Set<string>();58	let head: string | null = null;59	for (let pass = 0; ; pass++) {60		const now = await text(bucket, `${from}/head`);61		if (now === null) throw new Error(`${from} has no head in the bucket.`);62		if (now === head) break;63		if (pass === 5) throw new Error(`${from} kept moving while it was copied; try again.`);64		const keys = (await bucket.list(`${from}/`)).filter(65			(key) => key !== `${from}/head` && !(IMMUTABLE.test(key) && copied.has(key)),66		);67		await inTurn(keys, AT_ONCE, (key) => bucket.copy(key, `${to}/${key.slice(from.length + 1)}`));68		for (const key of keys) copied.add(key);69		await bucket.copy(`${from}/head`, `${to}/head`);70		head = now;71	}72	await bucket.put(73		`${KEPR_PREFIX}renamed/${from}`,74		new TextEncoder().encode(`${to}\n`),75		"text/plain",76	);77	const hidden = await bucket.get(`${KEPR_PREFIX}hidden/${from}`);78	if (hidden) {79		await bucket.put(`${KEPR_PREFIX}hidden/${to}`, hidden, "text/plain");80		await bucket.remove(`${KEPR_PREFIX}hidden/${from}`);81	}82	await dropTarballs(bucket, from);83	moveRepoRows(kepr, from, to);84	closeMirror(mirrorDir, from);85	for (const suffix of ["", "-wal", "-shm"]) {86		const path = join(mirrorDir, `${from}.db${suffix}`);87		if (existsSync(path)) renameSync(path, join(mirrorDir, `${to}.db${suffix}`));88	}89	await inTurn(await bucket.list(`${from}/`), AT_ONCE, (key) => bucket.removeRenamed(key));90}9192async function text(bucket: Bucket, key: string): Promise<string | null> {93	const bytes = await bucket.get(key);94	return bytes && new TextDecoder().decode(bytes).trim();95}