export const DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS = 3; export function isMissingDirectoryReplacePathError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /\bENOENT\b|no such file|not found|does not exist/i.test(message); } export async function deleteDirectoryReplaceBackup( deleteBackup: () => Promise, options: { attempts?: number; delay?: (attempt: number) => Promise; } = {}, ): Promise { const attempts = Math.max(1, options.attempts ?? DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS); const delay = options.delay ?? ((attempt: number) => new Promise((resolve) => { setTimeout(resolve, attempt * 25); })); let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt += 1) { try { await deleteBackup(); return; } catch (error) { lastError = error; if (attempt < attempts) await delay(attempt); } } throw lastError; } export interface DirectoryReplacePromotionOptions { targetPath: string; stagedPath: string; backupPath: string; statPath: (path: string) => Promise; renamePath: (source: string, target: string) => Promise; deletePath: (path: string) => Promise; } async function pathExists( statPath: DirectoryReplacePromotionOptions["statPath"], candidate: string, ): Promise { try { return Boolean(await statPath(candidate)); } catch (error) { if (isMissingDirectoryReplacePathError(error)) return false; throw error; } } function createDirectoryReplaceRecoveryError( promotionError: unknown, restoreError: unknown, options: Pick, ): Error { const error = new Error( `Directory replacement failed and the original could not be restored. ` + `Target: ${options.targetPath}; backup: ${options.backupPath}; stage: ${options.stagedPath}. ` + `Promotion error: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}. ` + `Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`, ); (error as Error & { cause?: unknown }).cause = promotionError; return error; } /** * Publish a fully-written replacement directory with one shared recovery rule. * * Both live transfers and restart resume use this transaction: * - restore the only known-good backup before touching anything else; * - remove only a stale backup that accompanies a live final target; * - ignore only a genuinely missing final target; * - restore the original if stage promotion fails; * - keep a committed target and its backup recoverable if cleanup keeps failing. */ export async function promoteDirectoryReplaceStage( options: DirectoryReplacePromotionOptions, ): Promise { const { targetPath, stagedPath, backupPath, statPath, renamePath, deletePath, } = options; if (!targetPath || !stagedPath || !backupPath || stagedPath === targetPath) { throw new Error("Invalid directory replacement paths"); } const backupExists = await pathExists(statPath, backupPath); if (backupExists) { if (await pathExists(statPath, targetPath)) { await deleteDirectoryReplaceBackup(() => deletePath(backupPath)); } else { // An interrupted prior commit left the backup as the only known-good tree. // Restore it before creating a new backup or publishing a rebuilt stage. await renamePath(backupPath, targetPath); } } let backedUp = false; try { await renamePath(targetPath, backupPath); backedUp = true; } catch (error) { // Permission, conflict, and transport failures must stop publication. Only // a missing target is safe to treat as a new-directory replacement. if (!isMissingDirectoryReplacePathError(error)) throw error; } try { await renamePath(stagedPath, targetPath); } catch (promotionError) { if (backedUp) { try { await renamePath(backupPath, targetPath); } catch (restoreError) { throw createDirectoryReplaceRecoveryError(promotionError, restoreError, options); } } throw promotionError; } if (backedUp) { // Publication is already committed. Persistent cleanup failure deliberately // leaves both final and backup present and reports a retryable failure. await deleteDirectoryReplaceBackup(() => deletePath(backupPath)); } }