@cloudant/couchbackup
Version:
CouchBackup - command-line backup utility for Cloudant/CouchDB
152 lines (143 loc) • 7.11 kB
JavaScript
// Copyright © 2017, 2024 IBM Corp. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const { BackupError } = require('./error.js');
const debug = require('debug');
const mappingDebug = debug('couchbackup:mappings');
const marker = '@cloudant/couchbackup:resume';
const RESUME_COMMENT = `${JSON.stringify({ marker })}`; // Special marker for resumes
class Restore {
// For compatibility with old versions ignore all broken JSON by default.
// (Old versions did not have a distinguishable resume marker).
// If we are restoring a backup file from a newer version we'll read the metadata
// and change the flag.
suppressAllBrokenJSONErrors = true;
backupMode;
constructor(dbClient, options) {
this.dbClient = dbClient;
this.options = options;
this.batchCounter = 0;
}
/**
* Mapper for converting a backup file line to an array of documents pending restoration.
*
* @param {object} backupLine object representation of a backup file line {lineNumber: #, line: '...'}
* @returns {array} array of documents parsed from the line or an empty array for invalid lines
*/
backupLineToDocsArray = (backupLine) => {
if (backupLine && backupLine.line !== '' && backupLine.line !== RESUME_COMMENT) {
// see if it parses as JSON
let lineAsJson;
try {
lineAsJson = JSON.parse(backupLine.line);
} catch (err) {
mappingDebug(`Invalid JSON on line ${backupLine.lineNumber} of backup file.`);
if (this.suppressAllBrokenJSONErrors) {
// The backup file comes from an older version of couchbackup that predates RESUME_COMMENT.
// For compatibility ignore the broken JSON line assuming it was part of a resume.
mappingDebug(`Ignoring invalid JSON on line ${backupLine.lineNumber} of backup file as it was written by couchbackup version < 2.10.0 and could be a valid resume point.`);
return [];
} else if (this.backupMode === 'full' && backupLine.line.slice(-RESUME_COMMENT.length) === RESUME_COMMENT) {
mappingDebug(`Ignoring invalid JSON on line ${backupLine.lineNumber} of full mode backup file as it was resumed.`);
return [];
} else {
// If the backup wasn't resumed and we aren't ignoring errors then it is invalid and we should error
throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - cannot parse as JSON`);
}
}
// if it's an array
if (lineAsJson && Array.isArray(lineAsJson)) {
return lineAsJson;
} else if (backupLine.lineNumber === 1 && lineAsJson.name && lineAsJson.version && lineAsJson.mode) {
// First line is metadata.
mappingDebug(`Parsed backup file metadata ${lineAsJson.name} ${lineAsJson.version} ${lineAsJson.mode} ${lineAsJson.attachments}.`);
// This identifies a version of 2.10.0 or newer that wrote the backup file.
// Set the mode that was used for the backup file.
this.backupMode = lineAsJson.mode;
// For newer versions we don't need to ignore all broken JSON, only ones that
// were associated wiht a resume, so unset the ignore flag.
this.suppressAllBrokenJSONErrors = false;
// Later we may add other version/feature specific toggles here.
if (lineAsJson.attachments === true) {
if (!this.options.attachments) {
// Error out if trying to restore attachments without the option
throw new BackupError('AttachmentsNotEnabledError', 'To restore a backup file with attachments, enable the attachments option.');
}
} else {
if (this.options.attachments) {
throw new BackupError('AttachmentsMetadataAbsent', 'Cannot restore with attachments because the backup file was not created with the attachments option.');
}
}
} else if (lineAsJson.marker && lineAsJson.marker === marker) {
mappingDebug(`Resume marker on line ${backupLine.lineNumber} of backup file.`);
} else {
throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - not an array or expected metadata`);
}
}
// Return an empty array if there was a blank line (including a line of only the resume marker)
return [];
};
/**
* Mapper to wrap an array of docs in batch metadata
* @param {array} docs an array of documents to be restored
* @returns {object} a pending restore batch {batch: #, docs: [...]}
*/
docsToRestoreBatch = (docs) => {
return { batch: this.batchCounter++, docs };
};
/**
* Mapper for converting a pending restore batch to a _bulk_docs request
* and awaiting the response and finally returing a "restored" object
* with the batch number and number of restored docs.
*
* @param {object} restoreBatch a pending restore batch {batch: #, docs: [{_id: id, ...}, ...]}
* @returns {object} a restored batch object { batch: #, documents: #}
*/
pendingToRestored = async (restoreBatch) => {
// Save the batch number
const batch = restoreBatch.batch;
mappingDebug(`Preparing to restore ${batch}`);
// Remove it from the restoreBatch since we'll use that as our payload
delete restoreBatch.batch;
if (!restoreBatch.docs || restoreBatch.docs.length === 0) {
mappingDebug(`Nothing to restore in batch ${batch}.`);
return { batch, documents: 0 };
}
mappingDebug(`Restoring batch ${batch} with ${restoreBatch.docs.length} docs.`);
// if we are restoring known revisions, we need to supply newEdits=false
if (restoreBatch.docs[0] && restoreBatch.docs[0]._rev) {
restoreBatch.newEdits = false;
mappingDebug('Using newEdits false mode.');
}
try {
const response = await this.dbClient.service.postBulkDocs({
db: this.dbClient.dbName,
bulkDocs: restoreBatch
});
if (!response.result || (restoreBatch.newEdits === false && response.result.length > 0)) {
mappingDebug(`Some errors restoring batch ${batch}.`);
throw new Error(`Error writing batch ${batch} with newEdits:${restoreBatch.newEdits !== false}` +
` and ${response.result ? response.result.length : 'unavailable'} items`);
}
mappingDebug(`Successfully restored batch ${batch}.`);
return { batch, documents: restoreBatch.docs.length };
} catch (err) {
mappingDebug(`Error writing docs when restoring batch ${batch}`);
throw err;
}
};
}
module.exports = {
Restore,
RESUME_COMMENT
};