/** * Restore Command * * Import/restore a database to postgres.do * * @module cli/commands/restore */ import { createReadStream, existsSync, statSync } from 'node:fs' import { basename, resolve } from 'node:path' import { requireToken } from '../cli-auth.js' import { getApiUrl, addRecentDatabase } from '../config.js' import { printResult, printInfo, printVerbose, formatBytes, formatDuration, type OutputOptions } from '../output.js' /** * Restore command options */ export interface RestoreOptions extends OutputOptions { /** Input file path */ file: string /** Target database name (will create if doesn't exist) */ database?: string | undefined /** Drop existing objects before restore */ clean?: boolean | undefined /** Create database before restore */ create?: boolean | undefined /** Restore data only (no schema) */ dataOnly?: boolean | undefined /** Restore schema only (no data) */ schemaOnly?: boolean | undefined /** Specific tables to restore (comma-separated) */ tables?: string | undefined /** Continue on error */ ignoreErrors?: boolean | undefined /** Number of parallel jobs */ jobs?: number | undefined /** API URL override */ apiUrl?: string | undefined } /** * Restore result info */ export interface RestoreResult { database: string inputFile: string size: number duration: number tablesRestored?: number rowsRestored?: number errors?: string[] } /** * Run restore command */ export async function runRestore(options: RestoreOptions): Promise { const { file, database, clean = false, create = false, dataOnly = false, schemaOnly = false, tables, ignoreErrors = false, jobs, } = options if (!file) { printResult({ success: false, error: 'Input file is required', }, options) process.exit(1) } // Resolve and validate input file const inputFile = resolve(process.cwd(), file) if (!existsSync(inputFile)) { printResult({ success: false, error: `File not found: ${inputFile}`, }, options) process.exit(1) } const fileStats = statSync(inputFile) if (!fileStats.isFile()) { printResult({ success: false, error: `Not a file: ${inputFile}`, }, options) process.exit(1) } // Determine database name from file if not provided const targetDatabase = database || inferDatabaseName(basename(inputFile)) try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Restoring to database '${targetDatabase}'...`, options) printVerbose(`Input file: ${inputFile} (${formatBytes(fileStats.size)})`, options) const startTime = Date.now() // Build restore request URL const params = new URLSearchParams() if (clean) params.set('clean', 'true') if (create) params.set('create', 'true') if (dataOnly) params.set('data_only', 'true') if (schemaOnly) params.set('schema_only', 'true') if (tables) params.set('tables', tables) if (ignoreErrors) params.set('ignore_errors', 'true') if (jobs) params.set('jobs', String(jobs)) // Detect format from file extension const format = detectFormat(inputFile) params.set('format', format) // Create file stream const fileStream = createReadStream(inputFile) // Upload and restore const response = await fetch(`${apiUrl}/v1/databases/${targetDatabase}/restore?${params}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': getContentType(format), 'Content-Length': String(fileStats.size), }, body: fileStream as unknown as BodyInit, duplex: 'half', } as RequestInit) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to restore database' })) printResult({ success: false, error: (error as { message?: string; error?: string }).message || (error as { error?: string }).error || 'Failed to restore database', }, options) process.exit(1) } const result = await response.json() as { tablesRestored?: number rowsRestored?: number errors?: string[] } const duration = Date.now() - startTime // Add to recent databases addRecentDatabase(targetDatabase) const restoreResult: RestoreResult = { database: targetDatabase, inputFile, size: fileStats.size, duration, } if (result.tablesRestored !== undefined) restoreResult.tablesRestored = result.tablesRestored if (result.rowsRestored !== undefined) restoreResult.rowsRestored = result.rowsRestored if (result.errors) restoreResult.errors = result.errors const hasErrors = result.errors && result.errors.length > 0 printResult({ success: !hasErrors || ignoreErrors, message: `Restore completed${hasErrors ? ' with errors' : ''}: ${formatBytes(fileStats.size)} in ${formatDuration(duration)}`, data: restoreResult, }, options) if (hasErrors && !ignoreErrors) { process.exit(1) } } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error during restore', }, options) process.exit(1) } } /** * Restore from a URL */ export async function runRestoreFromUrl( url: string, options: RestoreOptions ): Promise { const { database } = options if (!database) { printResult({ success: false, error: 'Database name is required when restoring from URL', }, options) process.exit(1) } try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Restoring from URL to database '${database}'...`, options) printVerbose(`Source URL: ${url}`, options) const startTime = Date.now() // Build restore request const params = new URLSearchParams() if (options.clean) params.set('clean', 'true') if (options.create) params.set('create', 'true') if (options.dataOnly) params.set('data_only', 'true') if (options.schemaOnly) params.set('schema_only', 'true') if (options.tables) params.set('tables', options.tables) if (options.ignoreErrors) params.set('ignore_errors', 'true') const response = await fetch(`${apiUrl}/v1/databases/${database}/restore-url?${params}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url }), }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to restore from URL' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to restore from URL', }, options) process.exit(1) } const result = await response.json() as { tablesRestored?: number rowsRestored?: number errors?: string[] } const duration = Date.now() - startTime // Add to recent databases addRecentDatabase(database) printResult({ success: true, message: `Restore completed in ${formatDuration(duration)}`, data: { database, sourceUrl: url, duration, ...result, }, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error during restore', }, options) process.exit(1) } } /** * Restore from another postgres.do database */ export async function runRestoreFromDatabase( source: string, target: string, options: OutputOptions & { apiUrl?: string; clean?: boolean } ): Promise { try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Cloning database '${source}' to '${target}'...`, options) const startTime = Date.now() const params = new URLSearchParams() if (options.clean) params.set('clean', 'true') const response = await fetch(`${apiUrl}/v1/databases/${target}/clone?${params}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ source }), }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to clone database' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to clone database', }, options) process.exit(1) } const result = await response.json() const duration = Date.now() - startTime // Add to recent databases addRecentDatabase(target) printResult({ success: true, message: `Database cloned successfully in ${formatDuration(duration)}`, data: { source, target, duration, ...result, }, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error during clone', }, options) process.exit(1) } } /** * Infer database name from filename */ function inferDatabaseName(filename: string): string { // Remove extension and timestamp patterns let name = filename .replace(/\.(sql|dump|tar)(\.gz)?$/i, '') .replace(/-backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}$/i, '') .replace(/-\d{14}$/i, '') // Alternative timestamp format // Ensure valid database name name = name.toLowerCase().replace(/[^a-z0-9_-]/g, '_') if (!name || !/^[a-z]/.test(name)) { name = 'restored_' + name } return name.slice(0, 63) } /** * Detect backup format from file extension */ function detectFormat(filename: string): string { const lower = filename.toLowerCase() if (lower.endsWith('.dump') || lower.endsWith('.dump.gz')) { return 'custom' } if (lower.endsWith('.tar') || lower.endsWith('.tar.gz')) { return 'tar' } // Default to SQL return 'sql' } /** * Get content type for format */ function getContentType(format: string): string { switch (format) { case 'custom': return 'application/octet-stream' case 'tar': return 'application/x-tar' case 'sql': default: return 'application/sql' } }