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";101112const NEW_NAME = /^[a-z0-9_-][a-z0-9._-]{0,99}$/;1314const IMMUTABLE = /^[^/]+\/(objects|saves|events)\//;1516const AT_ONCE = 8;171819export const renames = new Map<string, string>();20export const renameErrors = new Map<string, string>();212223export 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}363738394041424344export 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}