hana-cli
Version:
HANA Developer Command Line Interface
486 lines (428 loc) • 15.3 kB
JavaScript
// @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 = 'restore [backupFile]'
export const aliases = ['rst', 'restoreBackup']
export const describe = baseLite.bundle.getText("restore")
export const builder = (yargs) => yargs.options(baseLite.getBuilder({
backupFile: {
alias: ['bf', 'file'],
type: 'string',
desc: baseLite.bundle.getText("restoreBackupFile")
},
target: {
alias: ['tgt'],
type: 'string',
desc: baseLite.bundle.getText("restoreTarget")
},
schema: {
alias: ['s'],
type: 'string',
desc: baseLite.bundle.getText("schema")
},
overwrite: {
alias: ['ow'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("restoreOverwrite")
},
dropExisting: {
alias: ['de'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("restoreDropExisting")
},
continueOnError: {
alias: ['coe'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("restoreContinueOnError")
},
batchSize: {
alias: ['b', 'batch'],
type: 'number',
default: 1000,
desc: baseLite.bundle.getText("restoreBatchSize")
},
dryRun: {
alias: ['dr', 'preview'],
type: 'boolean',
default: false,
desc: baseLite.bundle.getText("restoreDryRun")
}
})).wrap(160).example('hana-cli restore --backupFile backup.db', baseLite.bundle.getText("restoreExample")).wrap(160).epilog(buildDocEpilogue('restore', 'backup-recovery', ['backup', 'backupList', 'backupStatus']))
export let inputPrompts = {
backupFile: {
description: baseLite.bundle.getText("restoreBackupFile"),
type: 'string',
required: true
},
target: {
description: baseLite.bundle.getText("restoreTarget"),
type: 'string',
required: false
},
schema: {
description: baseLite.bundle.getText("schema"),
type: 'string',
required: false
},
overwrite: {
description: baseLite.bundle.getText("restoreOverwrite"),
type: 'boolean',
required: false
},
dropExisting: {
description: baseLite.bundle.getText("restoreDropExisting"),
type: 'boolean',
required: false
},
continueOnError: {
description: baseLite.bundle.getText("restoreContinueOnError"),
type: 'boolean',
required: false
},
batchSize: {
description: baseLite.bundle.getText("restoreBatchSize"),
type: 'number',
required: false
},
dryRun: {
description: baseLite.bundle.getText("restoreDryRun"),
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, restoreBackup, inputPrompts)
}
/**
* Restore database object(s) from backup
* @param {object} prompts - Input prompts with restore configuration
* @returns {Promise<object>} - Restore result
*/
export async function restoreBackup(prompts) {
const base = await import('../utils/base.js')
const dbClientModule = await import("../utils/database/index.js")
const dbClientClass = dbClientModule.default
try {
base.debug('restoreBackup')
// Resolve backup file path
let backupPath = prompts.backupFile
if (!path.isAbsolute(backupPath)) {
const defaultBackupDir = path.join(homedir(), '.hana-cli', 'backups')
backupPath = path.join(defaultBackupDir, backupPath)
}
// Check if backup file exists
if (!fs.existsSync(backupPath)) {
throw new Error(base.bundle.getText("restoreFileNotFound", [backupPath]))
}
// Load backup metadata
const metadataPath = `${backupPath}.meta.json`
let metadata = null
if (fs.existsSync(metadataPath)) {
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'))
}
console.log(base.bundle.getText("restoreStarting", [backupPath]))
if (prompts.dryRun) {
console.log(base.bundle.getText("restoreDryRunMode"))
if (metadata) {
console.log(`\n${base.bundle.getText("restoreMetadata")}:`)
base.outputTable([metadata])
}
return { status: 'dry-run', metadata }
}
// Connect to database
const dbClient = await dbClientClass.getNewClient(prompts)
await dbClient.connect()
const db = dbClient.getDB()
let result
if (metadata) {
// Restore based on backup type
switch (metadata.type) {
case 'table':
result = await restoreTable(db, backupPath, metadata, prompts)
break
case 'schema':
result = await restoreSchema(db, backupPath, metadata, prompts)
break
case 'database':
result = await restoreDatabase(db, backupPath, metadata, prompts)
break
default:
throw new Error(base.bundle.getText("restoreUnknownType", [metadata.type]))
}
} else {
// Try to restore without metadata
console.warn(base.bundle.getText("restoreNoMetadata"))
result = await restoreTable(db, backupPath, null, prompts)
}
console.log(base.bundle.getText("restoreCompleted"))
base.outputTable([result])
await dbClient.disconnect()
return result
} catch (error) {
console.error(base.bundle.getText("restoreFailed", [error.message]))
throw error
}
}
/**
* Restore a single table
* @param {object} db - Database connection
* @param {string} backupPath - Backup file path
* @param {object} metadata - Backup metadata
* @param {object} options - Restore options
* @returns {Promise<object>} - Restore result
*/
async function restoreTable(db, backupPath, metadata, options) {
const base = await import('../utils/base.js')
let backupData
const csvPath = backupPath.replace('.backup', '.csv')
const csvGzPath = `${csvPath}.gz`
// Load backup data
if (fs.existsSync(csvGzPath)) {
// Decompress and load CSV
const { gunzip } = await import('zlib')
const { promisify } = await import('util')
const gunzipAsync = promisify(gunzip)
const compressed = fs.readFileSync(csvGzPath)
const decompressed = await gunzipAsync(compressed)
backupData = { data: parseCSV(decompressed.toString()) }
} else if (fs.existsSync(csvPath)) {
// Load uncompressed CSV
const csvContent = fs.readFileSync(csvPath, 'utf8')
backupData = { data: parseCSV(csvContent) }
} else if (fs.existsSync(backupPath)) {
// Load JSON backup
const content = fs.readFileSync(backupPath, 'utf8')
backupData = JSON.parse(content)
} else {
throw new Error(base.bundle.getText("restoreDataNotFound", [backupPath]))
}
// Determine target schema and table
const targetSchema = options.schema || (metadata ? metadata.schema : null)
const targetTable = options.target || (metadata ? metadata.table : null)
if (!targetSchema || !targetTable) {
throw new Error(base.bundle.getText("restoreTargetRequired"))
}
console.log(base.bundle.getText("restoreTableTarget", [targetSchema, targetTable]))
// Check if table exists
const checkTableQuery = `
SELECT COUNT(*) as COUNT
FROM TABLES
WHERE SCHEMA_NAME = ? AND TABLE_NAME = ?
`
const tableExists = await db.statementExecPromisified(
await db.preparePromisified(checkTableQuery),
[targetSchema, targetTable]
)
if (tableExists[0].COUNT > 0) {
if (options.dropExisting) {
console.log(base.bundle.getText("restoreDroppingTable", [targetTable]))
await db.statementExecPromisified(
await db.preparePromisified(`DROP TABLE "${targetSchema}"."${targetTable}"`),
[]
)
} else if (!options.overwrite) {
throw new Error(base.bundle.getText("restoreTableExists", [targetTable]))
}
}
// Create table if it doesn't exist and we have metadata
if (metadata && metadata.columns && tableExists[0].COUNT === 0) {
console.log(base.bundle.getText("restoreCreatingTable", [targetTable]))
const createTableSQL = generateCreateTableSQL(targetSchema, targetTable, metadata.columns)
await db.statementExecPromisified(await db.preparePromisified(createTableSQL), [])
}
// Insert data
let recordsInserted = 0
if (backupData.data && backupData.data.length > 0) {
console.log(base.bundle.getText("restoreInsertingRecords", [backupData.data.length]))
// Truncate if overwrite is enabled
if (options.overwrite && tableExists[0].COUNT > 0) {
await db.statementExecPromisified(
await db.preparePromisified(`TRUNCATE TABLE "${targetSchema}"."${targetTable}"`),
[]
)
}
// Insert in batches
const columns = Object.keys(backupData.data[0])
const placeholders = columns.map(() => '?').join(', ')
const insertSQL = `INSERT INTO "${targetSchema}"."${targetTable}" (${columns.map(c => `"${c}"`).join(', ')}) VALUES (${placeholders})`
const insertStmt = await db.preparePromisified(insertSQL)
for (let i = 0; i < backupData.data.length; i += options.batchSize) {
const batch = backupData.data.slice(i, i + options.batchSize)
for (const row of batch) {
try {
const values = columns.map(col => row[col])
await db.statementExecPromisified(insertStmt, values)
recordsInserted++
} catch (error) {
if (!options.continueOnError) {
throw error
}
console.warn(base.bundle.getText("restoreRecordFailed", [i, error.message]))
}
}
console.log(base.bundle.getText("restoreProgress", [recordsInserted, backupData.data.length]))
}
}
return {
status: 'completed',
schema: targetSchema,
table: targetTable,
recordsInserted: recordsInserted,
completedAt: new Date().toISOString()
}
}
/**
* Restore entire schema
* @param {object} db - Database connection
* @param {string} backupPath - Backup file path
* @param {object} metadata - Backup metadata
* @param {object} options - Restore options
* @returns {Promise<object>} - Restore result
*/
async function restoreSchema(db, backupPath, metadata, options) {
const base = await import('../utils/base.js')
// Load schema manifest
const manifestPath = `${backupPath}.manifest.json`
if (!fs.existsSync(manifestPath)) {
throw new Error(base.bundle.getText("restoreManifestNotFound", [manifestPath]))
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
const targetSchema = options.schema || manifest.schema
console.log(base.bundle.getText("restoreSchemaStarting", [manifest.tables.length, targetSchema]))
let results = []
for (const tableInfo of manifest.tables) {
console.log(base.bundle.getText("restoreTableProgress", [tableInfo.name]))
try {
const tableBackupPath = tableInfo.backupPath
const tableMetadataPath = `${tableBackupPath}.meta.json`
const tableMetadata = fs.existsSync(tableMetadataPath)
? JSON.parse(fs.readFileSync(tableMetadataPath, 'utf8'))
: null
const result = await restoreTable(db, tableBackupPath, tableMetadata, {
...options,
schema: targetSchema,
target: tableInfo.name
})
results.push({ table: tableInfo.name, status: 'completed', ...result })
} catch (error) {
if (!options.continueOnError) {
throw error
}
console.warn(base.bundle.getText("restoreTableSkipped", [tableInfo.name, error.message]))
results.push({ table: tableInfo.name, status: 'failed', error: error.message })
}
}
return {
status: 'completed',
schema: targetSchema,
tablesRestored: results.filter(r => r.status === 'completed').length,
tablesFailed: results.filter(r => r.status === 'failed').length,
results: results
}
}
/**
* Restore entire database
* @param {object} db - Database connection
* @param {string} backupPath - Backup file path
* @param {object} metadata - Backup metadata
* @param {object} options - Restore options
* @returns {Promise<object>} - Restore result
*/
async function restoreDatabase(db, backupPath, metadata, options) {
const base = await import('../utils/base.js')
// Load database manifest
const manifestPath = `${backupPath}.manifest.json`
if (!fs.existsSync(manifestPath)) {
throw new Error(base.bundle.getText("restoreManifestNotFound", [manifestPath]))
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
console.log(base.bundle.getText("restoreDatabaseStarting", [manifest.schemas.length]))
let results = []
for (const schemaInfo of manifest.schemas) {
if (schemaInfo.status === 'failed') {
console.log(base.bundle.getText("restoreSchemaSkipped", [schemaInfo.name, 'Schema was not backed up']))
continue
}
console.log(base.bundle.getText("restoreSchemaProgress", [schemaInfo.name]))
try {
const schemaBackupPath = schemaInfo.backupPath
const schemaMetadata = { schema: schemaInfo.name }
const result = await restoreSchema(db, schemaBackupPath, schemaMetadata, {
...options,
schema: schemaInfo.name
})
results.push({ schema: schemaInfo.name, status: 'completed', ...result })
} catch (error) {
if (!options.continueOnError) {
throw error
}
console.warn(base.bundle.getText("restoreSchemaFailed", [schemaInfo.name, error.message]))
results.push({ schema: schemaInfo.name, status: 'failed', error: error.message })
}
}
return {
status: 'completed',
schemasRestored: results.filter(r => r.status === 'completed').length,
schemasFailed: results.filter(r => r.status === 'failed').length,
results: results
}
}
/**
* Generate CREATE TABLE SQL from column metadata
* @param {string} schema - Schema name
* @param {string} tableName - Table name
* @param {Array} columns - Column metadata array
* @returns {string} - CREATE TABLE SQL statement
*/
function generateCreateTableSQL(schema, tableName, columns) {
const columnDefs = columns.map(col => {
let def = `"${col.COLUMN_NAME}" ${col.DATA_TYPE_NAME}`
if (col.LENGTH) {
def += `(${col.LENGTH}`
if (col.SCALE) {
def += `,${col.SCALE}`
}
def += ')'
}
if (col.IS_NULLABLE === 'FALSE') {
def += ' NOT NULL'
}
if (col.DEFAULT_VALUE) {
def += ` DEFAULT ${col.DEFAULT_VALUE}`
}
return def
}).join(', ')
return `CREATE TABLE "${schema}"."${tableName}" (${columnDefs})`
}
/**
* Parse CSV content to array of objects
* @param {string} csvContent - CSV content
* @returns {Array} - Array of row objects
*/
function parseCSV(csvContent) {
const lines = csvContent.split('\n').filter(line => line.trim())
if (lines.length === 0) return []
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''))
const rows = []
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''))
const row = {}
headers.forEach((header, index) => {
row[header] = values[index]
})
rows.push(row)
}
return rows
}