1import { existsSync, renameSync } from "node:fs";2import { join } from "node:path";3import { type Bucket, inTurn, 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";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 (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}394041424344454647export 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, AT_ONCE, (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}/`), AT_ONCE, (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}