/** * Backup Command * * Export/backup a database from postgres.do * * @module cli/commands/backup */ import { createWriteStream, existsSync, mkdirSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' import { requireToken } from '../cli-auth.js' import { getApiUrl } from '../config.js' import { printResult, printInfo, printVerbose, formatBytes, formatDuration, type OutputOptions } from '../output.js' /** * Backup format type */ export type BackupFormat = 'sql' | 'custom' | 'directory' | 'tar' /** * Backup command options */ export interface BackupOptions extends OutputOptions { /** Database name or ID */ name: string /** Output file path */ output?: string | undefined /** Backup format */ backupFormat?: BackupFormat | undefined /** Include schema only (no data) */ schemaOnly?: boolean | undefined /** Include data only (no schema) */ dataOnly?: boolean | undefined /** Specific tables to backup (comma-separated) */ tables?: string | undefined /** Tables to exclude (comma-separated) */ excludeTables?: string | undefined /** Compress output */ compress?: boolean | undefined /** API URL override */ apiUrl?: string | undefined } /** * Backup result info */ export interface BackupResult { database: string outputFile: string size: number backupFormat: string duration: number tables?: string[] } /** * Run backup command */ export async function runBackup(options: BackupOptions): Promise { const { name, output, backupFormat = 'sql', schemaOnly = false, dataOnly = false, tables, excludeTables, compress = false, } = options if (!name) { printResult({ success: false, error: 'Database name is required', }, options) process.exit(1) } // Determine output file const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) const extension = getExtension(backupFormat, compress) const outputFile = output || resolve(process.cwd(), `${name}-backup-${timestamp}${extension}`) try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Backing up database '${name}'...`, options) printVerbose(`Output: ${outputFile}`, options) printVerbose(`Format: ${backupFormat}`, options) const startTime = Date.now() // Build backup request const params = new URLSearchParams() params.set('format', backupFormat) if (schemaOnly) params.set('schema_only', 'true') if (dataOnly) params.set('data_only', 'true') if (tables) params.set('tables', tables) if (excludeTables) params.set('exclude_tables', excludeTables) if (compress) params.set('compress', 'true') // Request backup from API const response = await fetch(`${apiUrl}/v1/databases/${name}/backup?${params}`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to create backup' })) printResult({ success: false, error: (error as { message?: string; error?: string }).message || (error as { error?: string }).error || 'Failed to create backup', }, options) process.exit(1) } // Ensure output directory exists const outputDir = dirname(outputFile) if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }) } // Stream response to file if (!response.body) { throw new Error('No response body received') } const fileStream = createWriteStream(outputFile) const readable = Readable.fromWeb(response.body as import('stream/web').ReadableStream) let bytesWritten = 0 readable.on('data', (chunk: Buffer) => { bytesWritten += chunk.length if (options.verbose) { process.stdout.write(`\rDownloaded: ${formatBytes(bytesWritten)}`) } }) await pipeline(readable, fileStream) if (options.verbose) { process.stdout.write('\n') } const duration = Date.now() - startTime const result: BackupResult = { database: name, outputFile, size: bytesWritten, backupFormat, duration, } if (tables) result.tables = tables.split(',') printResult({ success: true, message: `Backup completed: ${outputFile} (${formatBytes(bytesWritten)}) in ${formatDuration(duration)}`, data: result, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error during backup', }, options) process.exit(1) } } /** * List available backups for a database */ export async function runBackupList( name: string, options: OutputOptions & { apiUrl?: string } ): Promise { try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Fetching backups for '${name}'...`, options) const response = await fetch(`${apiUrl}/v1/databases/${name}/backups`, { headers: { 'Authorization': `Bearer ${token}`, }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to list backups' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to list backups', }, options) process.exit(1) } const backups = await response.json() printResult({ success: true, data: backups, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error listing backups', }, options) process.exit(1) } } /** * Download a specific backup */ export async function runBackupDownload( name: string, backupId: string, options: OutputOptions & { apiUrl?: string; output?: string } ): Promise { try { const token = requireToken(options.apiUrl) const apiUrl = getApiUrl(options.apiUrl) printInfo(`Downloading backup '${backupId}'...`, options) const response = await fetch(`${apiUrl}/v1/databases/${name}/backups/${backupId}/download`, { headers: { 'Authorization': `Bearer ${token}`, }, }) if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Failed to download backup' })) printResult({ success: false, error: (error as { message?: string }).message || 'Failed to download backup', }, options) process.exit(1) } // Determine output file from Content-Disposition or generate one const contentDisposition = response.headers.get('Content-Disposition') const filenameMatch = contentDisposition?.match(/filename="?(.+)"?/) const filename = filenameMatch?.[1] || `${name}-${backupId}.sql` const outputFile = options.output || resolve(process.cwd(), filename) // Ensure output directory exists const outputDir = dirname(outputFile) if (!existsSync(outputDir)) { mkdirSync(outputDir, { recursive: true }) } // Stream response to file if (!response.body) { throw new Error('No response body received') } const fileStream = createWriteStream(outputFile) const readable = Readable.fromWeb(response.body as import('stream/web').ReadableStream) let bytesWritten = 0 readable.on('data', (chunk: Buffer) => { bytesWritten += chunk.length if (options.verbose) { process.stdout.write(`\rDownloaded: ${formatBytes(bytesWritten)}`) } }) await pipeline(readable, fileStream) if (options.verbose) { process.stdout.write('\n') } printResult({ success: true, message: `Backup downloaded: ${outputFile} (${formatBytes(bytesWritten)})`, data: { outputFile, size: bytesWritten }, }, options) } catch (error) { printResult({ success: false, error: error instanceof Error ? error.message : 'Unknown error downloading backup', }, options) process.exit(1) } } /** * Get file extension based on backupFormat and compression */ function getExtension(backupFormat: string, compress: boolean): string { const extensions: Record = { sql: '.sql', custom: '.dump', directory: '', tar: '.tar', } const ext = extensions[backupFormat] || '.sql' return compress && backupFormat === 'sql' ? `${ext}.gz` : ext }