UNPKG

hana-cli

Version:
425 lines (376 loc) 12.3 kB
// @ts-check import * as baseLite from '../utils/base-lite.js' import * as fs from 'fs' import * as path from 'path' import { homedir } from 'os' import { buildDocEpilogue } from '../utils/doc-linker.js' export const command = 'backup [target] [name]' export const aliases = ['bkp', 'createBackup'] export const describe = baseLite.bundle.getText("backup") export const builder = (yargs) => yargs.options(baseLite.getBuilder({ target: { alias: ['tgt'], type: 'string', desc: baseLite.bundle.getText("backupTarget") }, name: { alias: ['n'], type: 'string', desc: baseLite.bundle.getText("backupName") }, backupType: { alias: ['type'], choices: ["table", "schema", "database"], default: "table", type: 'string', desc: baseLite.bundle.getText("backupType") }, format: { alias: ['f'], choices: ["csv", "binary", "parquet"], default: "csv", type: 'string', desc: baseLite.bundle.getText("backupFormat") }, destination: { alias: ['dest'], type: 'string', desc: baseLite.bundle.getText("backupDestination") }, compress: { alias: ['c'], type: 'boolean', default: true, desc: baseLite.bundle.getText("backupCompress") }, schema: { alias: ['s'], type: 'string', default: '**CURRENT_SCHEMA**', desc: baseLite.bundle.getText("schema") }, withData: { alias: ['wd'], type: 'boolean', default: true, desc: baseLite.bundle.getText("backupWithData") }, overwrite: { alias: ['ow'], type: 'boolean', default: false, desc: baseLite.bundle.getText("backupOverwrite") } })).wrap(160).example('hana-cli backup --backupPath /backups', baseLite.bundle.getText("backupExample")).wrap(160).epilog(buildDocEpilogue('backup', 'backup-recovery', ['backupStatus', 'backupList', 'restore'])) export let inputPrompts = { target: { description: baseLite.bundle.getText("backupTarget"), type: 'string', required: true }, name: { description: baseLite.bundle.getText("backupName"), type: 'string', required: false }, backupType: { description: baseLite.bundle.getText("backupType"), type: 'string', required: true }, format: { description: baseLite.bundle.getText("backupFormat"), type: 'string', required: false }, destination: { description: baseLite.bundle.getText("backupDestination"), type: 'string', required: false }, compress: { description: baseLite.bundle.getText("backupCompress"), type: 'boolean', required: false }, schema: { description: baseLite.bundle.getText("schema"), type: 'string', required: false }, withData: { description: baseLite.bundle.getText("backupWithData"), type: 'boolean', required: false }, overwrite: { description: baseLite.bundle.getText("backupOverwrite"), type: 'boolean', required: false } } /** * Command handler function * @param {object} argv - Command line arguments from yargs * @returns {Promise<void>} */ export async function handler(argv) { const base = await import('../utils/base.js') base.promptHandler(argv, createBackup, inputPrompts) } /** * Create backup of database object(s) * @param {object} prompts - Input prompts with backup configuration * @returns {Promise<object>} - Backup metadata */ export async function createBackup(prompts) { const base = await import('../utils/base.js') const dbClientModule = await import("../utils/database/index.js") const dbClientClass = dbClientModule.default try { base.debug('createBackup') const dbClient = await dbClientClass.getNewClient(prompts) await dbClient.connect() const db = dbClient.getDB() // Determine schema const schema = prompts.schema === '**CURRENT_SCHEMA**' ? await base.dbClass.schemaCalc(prompts, db) : prompts.schema // Generate backup name if not provided const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) const backupName = prompts.name || `${prompts.target}_${timestamp}` // Determine destination directory const defaultBackupDir = path.join(homedir(), '.hana-cli', 'backups') const backupDir = prompts.destination || defaultBackupDir // Ensure backup directory exists if (!fs.existsSync(backupDir)) { fs.mkdirSync(backupDir, { recursive: true }) } const backupPath = path.join(backupDir, `${backupName}.backup`) // Check if backup already exists if (fs.existsSync(backupPath) && !prompts.overwrite) { throw new Error(base.bundle.getText("backupExists", [backupPath])) } let backupMetadata = { name: backupName, timestamp: new Date().toISOString(), type: prompts.backupType, target: prompts.target, schema: schema, format: prompts.format, compressed: prompts.compress, withData: prompts.withData, path: backupPath, status: 'in_progress' } console.log(base.bundle.getText("backupStarting", [prompts.backupType, prompts.target])) // Perform backup based on type switch (prompts.backupType) { case 'table': await backupTable(db, schema, prompts.target, backupPath, prompts) break case 'schema': await backupSchema(db, schema, backupPath, prompts) break case 'database': await backupDatabase(db, backupPath, prompts) break } backupMetadata.status = 'completed' backupMetadata.completedAt = new Date().toISOString() // Save metadata file const metadataPath = `${backupPath}.meta.json` fs.writeFileSync(metadataPath, JSON.stringify(backupMetadata, null, 2)) console.log(base.bundle.getText("backupCompleted", [backupPath])) base.outputTable([backupMetadata]) await dbClient.disconnect() return backupMetadata } catch (error) { console.error(base.bundle.getText("backupFailed", [error.message])) throw error } } /** * Backup a single table * @param {object} db - Database connection * @param {string} schema - Schema name * @param {string} tableName - Table name * @param {string} backupPath - Backup file path * @param {object} options - Backup options */ async function backupTable(db, schema, tableName, backupPath, options) { const base = await import('../utils/base.js') // Get table metadata const tableMetaQuery = ` SELECT COLUMN_NAME, DATA_TYPE_NAME, LENGTH, SCALE, IS_NULLABLE, DEFAULT_VALUE FROM TABLE_COLUMNS WHERE SCHEMA_NAME = ? AND TABLE_NAME = ? ORDER BY POSITION ` const tableMetadata = await db.statementExecPromisified( await db.preparePromisified(tableMetaQuery), [schema, tableName] ) let backupData = { metadata: { schema: schema, table: tableName, columns: tableMetadata, format: options.format }, data: [] } // Export data if requested if (options.withData) { const dataQuery = `SELECT * FROM "${schema}"."${tableName}"` backupData.data = await db.statementExecPromisified( await db.preparePromisified(dataQuery), [] ) console.log(base.bundle.getText("backupRecordsExported", [backupData.data.length])) } // Write backup file based on format if (options.format === 'csv') { await writeCSVBackup(backupPath, backupData, options.compress) } else { // For binary format, use JSON const content = options.compress ? await compressData(JSON.stringify(backupData)) : JSON.stringify(backupData, null, 2) fs.writeFileSync(backupPath, content) } } /** * Backup entire schema * @param {object} db - Database connection * @param {string} schema - Schema name * @param {string} backupPath - Backup file path * @param {object} options - Backup options */ async function backupSchema(db, schema, backupPath, options) { const base = await import('../utils/base.js') // Get all tables in schema const tablesQuery = ` SELECT TABLE_NAME FROM TABLES WHERE SCHEMA_NAME = ? ORDER BY TABLE_NAME ` const tables = await db.statementExecPromisified( await db.preparePromisified(tablesQuery), [schema] ) console.log(base.bundle.getText("backupSchemaTablesFound", [tables.length, schema])) let schemaBackup = { schema: schema, tables: [] } // Backup each table for (const table of tables) { console.log(base.bundle.getText("backupTableProgress", [table.TABLE_NAME])) const tableBackupPath = `${backupPath}_${table.TABLE_NAME}` await backupTable(db, schema, table.TABLE_NAME, tableBackupPath, options) schemaBackup.tables.push({ name: table.TABLE_NAME, backupPath: tableBackupPath }) } // Write schema manifest fs.writeFileSync(`${backupPath}.manifest.json`, JSON.stringify(schemaBackup, null, 2)) } /** * Backup entire database (metadata only - full database backup requires SYSTEM privileges) * @param {object} db - Database connection * @param {string} backupPath - Backup file path * @param {object} options - Backup options */ async function backupDatabase(db, backupPath, options) { const base = await import('../utils/base.js') console.log(base.bundle.getText("backupDatabaseWarning")) // Get database metadata const dbInfoQuery = `SELECT * FROM SYS.M_DATABASE` const dbInfo = await db.statementExecPromisified( await db.preparePromisified(dbInfoQuery), [] ) // Get all schemas const schemasQuery = ` SELECT SCHEMA_NAME FROM SCHEMAS WHERE SCHEMA_OWNER != 'SYS' ORDER BY SCHEMA_NAME ` const schemas = await db.statementExecPromisified( await db.preparePromisified(schemasQuery), [] ) console.log(base.bundle.getText("backupDatabaseSchemasFound", [schemas.length])) let databaseBackup = { database: dbInfo, schemas: [] } // Note: Full database backup requires SYSTEM user privileges // This creates a logical backup of accessible schemas for (const schemaRow of schemas) { const schemaName = schemaRow.SCHEMA_NAME console.log(base.bundle.getText("backupSchemaProgress", [schemaName])) const schemaBackupPath = `${backupPath}_${schemaName}` try { await backupSchema(db, schemaName, schemaBackupPath, options) databaseBackup.schemas.push({ name: schemaName, backupPath: schemaBackupPath, status: 'completed' }) } catch (error) { console.warn(base.bundle.getText("backupSchemaSkipped", [schemaName, error.message])) databaseBackup.schemas.push({ name: schemaName, status: 'failed', error: error.message }) } } // Write database manifest fs.writeFileSync(`${backupPath}.manifest.json`, JSON.stringify(databaseBackup, null, 2)) } /** * Write backup data to CSV format * @param {string} backupPath - Backup file path * @param {object} backupData - Backup data with metadata and rows * @param {boolean} compress - Whether to compress the output */ async function writeCSVBackup(backupPath, backupData, compress) { const { AsyncParser } = await import('@json2csv/node') const opts = {} const transformOpts = {} const asyncOpts = {} // Create CSV from data const parser = new AsyncParser(opts, transformOpts, asyncOpts) const csv = await parser.parse(backupData.data).promise() // Write metadata as separate JSON file fs.writeFileSync(`${backupPath}.meta.json`, JSON.stringify(backupData.metadata, null, 2)) // Write CSV data const csvPath = backupPath.replace('.backup', '.csv') fs.writeFileSync(csvPath, csv) if (compress) { // Compress CSV file (simple implementation - could use zlib for actual compression) const { gzip } = await import('zlib') const { promisify } = await import('util') const gzipAsync = promisify(gzip) const compressed = await gzipAsync(csv) fs.writeFileSync(`${csvPath}.gz`, compressed) fs.unlinkSync(csvPath) // Remove uncompressed version } } /** * Compress data using gzip * @param {string} data - Data to compress * @returns {Promise<Buffer>} - Compressed data */ async function compressData(data) { const { gzip } = await import('zlib') const { promisify } = await import('util') const gzipAsync = promisify(gzip) return await gzipAsync(data) }