rename.ts

4.3 KB · raw

1import { existsSync, renameSync } from "node:fs";2import { join } from "node:path";3import { type Bucket, KEPR_PREFIX } from "../bucket/reader.ts";4import { holdSync, 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(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}5455async 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}9091async 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}9596async 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}