@cloudant/couchbackup 2.9.17 → 2.10.0-206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ // Copyright © 2017, 2023 IBM Corp. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict';
15
+
16
+ const { BackupError } = require('./error.js');
17
+ const debug = require('debug');
18
+
19
+ const mappingDebug = debug('couchbackup:mappings');
20
+ const marker = '@cloudant/couchbackup:resume';
21
+ const RESUME_COMMENT = `${JSON.stringify({ marker })}`; // Special marker for resumes
22
+
23
+ class Restore {
24
+ // For compatibility with old versions ignore all broken JSON by default.
25
+ // (Old versions did not have a distinguishable resume marker).
26
+ // If we are restoring a backup file from a newer version we'll read the metadata
27
+ // and change the flag.
28
+ suppressAllBrokenJSONErrors = true;
29
+ backupMode;
30
+
31
+ constructor(dbClient) {
32
+ this.dbClient = dbClient;
33
+ this.batchCounter = 0;
34
+ }
35
+
36
+ /**
37
+ * Mapper for converting a backup file line to an array of documents pending restoration.
38
+ *
39
+ * @param {object} backupLine object representation of a backup file line {lineNumber: #, line: '...'}
40
+ * @returns {array} array of documents parsed from the line or an empty array for invalid lines
41
+ */
42
+ backupLineToDocsArray = (backupLine) => {
43
+ if (backupLine && backupLine.line !== '' && backupLine.line !== RESUME_COMMENT) {
44
+ // see if it parses as JSON
45
+ let lineAsJson;
46
+ try {
47
+ lineAsJson = JSON.parse(backupLine.line);
48
+ } catch (err) {
49
+ mappingDebug(`Invalid JSON on line ${backupLine.lineNumber} of backup file.`);
50
+ if (this.suppressAllBrokenJSONErrors) {
51
+ // The backup file comes from an older version of couchbackup that predates RESUME_COMMENT.
52
+ // For compatibility ignore the broken JSON line assuming it was part of a resume.
53
+ 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.`);
54
+ return [];
55
+ } else if (this.backupMode === 'full' && backupLine.line.slice(-RESUME_COMMENT.length) === RESUME_COMMENT) {
56
+ mappingDebug(`Ignoring invalid JSON on line ${backupLine.lineNumber} of full mode backup file as it was resumed.`);
57
+ return [];
58
+ } else {
59
+ // If the backup wasn't resumed and we aren't ignoring errors then it is invalid and we should error
60
+ throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - cannot parse as JSON`);
61
+ }
62
+ }
63
+ // if it's an array
64
+ if (lineAsJson && Array.isArray(lineAsJson)) {
65
+ return lineAsJson;
66
+ } else if (backupLine.lineNumber === 1 && lineAsJson.name && lineAsJson.version && lineAsJson.mode) {
67
+ // First line is metadata.
68
+ mappingDebug(`Parsed backup file metadata ${lineAsJson.name} ${lineAsJson.version} ${lineAsJson.mode}.`);
69
+ // This identifies a version of 2.10.0 or newer that wrote the backup file.
70
+ // Set the mode that was used for the backup file.
71
+ this.backupMode = lineAsJson.mode;
72
+ // For newer versions we don't need to ignore all broken JSON, only ones that
73
+ // were associated wiht a resume, so unset the ignore flag.
74
+ this.suppressAllBrokenJSONErrors = false;
75
+ // Later we may add other version/feature specific toggles here.
76
+ } else if (lineAsJson.marker && lineAsJson.marker === marker) {
77
+ mappingDebug(`Resume marker on line ${backupLine.lineNumber} of backup file.`);
78
+ } else {
79
+ throw new BackupError('BackupFileJsonError', `Error on line ${backupLine.lineNumber} of backup file - not an array or expected metadata`);
80
+ }
81
+ }
82
+ // Return an empty array if there was a blank line (including a line of only the resume marker)
83
+ return [];
84
+ };
85
+
86
+ /**
87
+ * Mapper to wrap an array of docs in batch metadata
88
+ * @param {array} docs an array of documents to be restored
89
+ * @returns {object} a pending restore batch {batch: #, docs: [...]}
90
+ */
91
+ docsToRestoreBatch = (docs) => {
92
+ return { batch: this.batchCounter++, docs };
93
+ };
94
+
95
+ /**
96
+ * Mapper for converting a pending restore batch to a _bulk_docs request
97
+ * and awaiting the response and finally returing a "restored" object
98
+ * with the batch number and number of restored docs.
99
+ *
100
+ * @param {object} restoreBatch a pending restore batch {batch: #, docs: [{_id: id, ...}, ...]}
101
+ * @returns {object} a restored batch object { batch: #, documents: #}
102
+ */
103
+ pendingToRestored = async(restoreBatch) => {
104
+ // Save the batch number
105
+ const batch = restoreBatch.batch;
106
+ mappingDebug(`Preparing to restore ${batch}`);
107
+ // Remove it from the restoreBatch since we'll use that as our payload
108
+ delete restoreBatch.batch;
109
+ if (!restoreBatch.docs || restoreBatch.docs.length === 0) {
110
+ mappingDebug(`Nothing to restore in batch ${batch}.`);
111
+ return { batch, documents: 0 };
112
+ }
113
+ mappingDebug(`Restoring batch ${batch} with ${restoreBatch.docs.length} docs.`);
114
+ // if we are restoring known revisions, we need to supply new_edits=false
115
+ if (restoreBatch.docs[0] && restoreBatch.docs[0]._rev) {
116
+ restoreBatch.new_edits = false;
117
+ mappingDebug('Using new_edits false mode.');
118
+ }
119
+ try {
120
+ const response = await this.dbClient.service.postBulkDocs({
121
+ db: this.dbClient.dbName,
122
+ bulkDocs: restoreBatch
123
+ });
124
+ if (!response.result || (restoreBatch.new_edits === false && response.result.length > 0)) {
125
+ mappingDebug(`Some errors restoring batch ${batch}.`);
126
+ throw new Error(`Error writing batch ${batch} with new_edits:${restoreBatch.new_edits !== false}` +
127
+ ` and ${response.result ? response.result.length : 'unavailable'} items`);
128
+ }
129
+ mappingDebug(`Successfully restored batch ${batch}.`);
130
+ return { batch, documents: restoreBatch.docs.length };
131
+ } catch (err) {
132
+ mappingDebug(`Error writing docs when restoring batch ${batch}`);
133
+ throw err;
134
+ }
135
+ };
136
+ }
137
+
138
+ module.exports = {
139
+ Restore,
140
+ RESUME_COMMENT
141
+ };
@@ -1,4 +1,4 @@
1
- // Copyright © 2017, 2022 IBM Corp. All rights reserved.
1
+ // Copyright © 2017, 2024 IBM Corp. All rights reserved.
2
2
  //
3
3
  // Licensed under the Apache License, Version 2.0 (the "License");
4
4
  // you may not use this file except in compliance with the License.
@@ -13,99 +13,77 @@
13
13
  // limitations under the License.
14
14
  'use strict';
15
15
 
16
- const fs = require('fs');
17
- const liner = require('./liner.js');
18
- const change = require('./change.js');
19
- const error = require('./error.js');
16
+ const { createWriteStream } = require('node:fs');
17
+ const { pipeline } = require('node:stream/promises');
18
+ const { BackupError } = require('./error.js');
19
+ const { BatchingStream, DelegateWritable, MappingStream } = require('./transforms.js');
20
20
  const debug = require('debug')('couchbackup:spoolchanges');
21
+ const { ChangesFollower } = require('@ibm-cloud/cloudant');
21
22
 
22
23
  /**
23
24
  * Write log file for all changes from a database, ready for downloading
24
25
  * in batches.
25
26
  *
26
- * @param {string} dbUrl - URL of database
27
+ * @param {object} dbClient - object for connection to source database containing name, service and url
27
28
  * @param {string} log - path to log file to use
29
+ * @param {function} eeFn - event emitter function to call after each write
28
30
  * @param {number} bufferSize - the number of changes per batch/log line
29
- * @param {function(err)} callback - a callback to run on completion
31
+ * @param {number} tolerance - changes follower error tolerance
30
32
  */
31
- module.exports = function(db, log, bufferSize, ee, callback) {
32
- // list of document ids to process
33
- const buffer = [];
33
+ module.exports = function(dbClient, log, eeFn, bufferSize = 500, tolerance = 600000) {
34
+ let lastSeq;
34
35
  let batch = 0;
35
- let lastSeq = null;
36
- const logStream = fs.createWriteStream(log);
37
- let pending = 0;
38
- // The number of changes to fetch per request
39
- const limit = 100000;
36
+ let totalBuffer = 0;
40
37
 
41
- // send documents ids to the queue in batches of bufferSize + the last batch
42
- const processBuffer = function(lastOne) {
43
- if (buffer.length >= bufferSize || (lastOne && buffer.length > 0)) {
44
- debug('writing', buffer.length, 'changes to the backup file');
45
- const b = { docs: buffer.splice(0, bufferSize), batch: batch };
46
- logStream.write(':t batch' + batch + ' ' + JSON.stringify(b.docs) + '\n');
47
- ee.emit('changes', batch);
48
- batch++;
38
+ class LogWriter extends DelegateWritable {
39
+ constructor(log) {
40
+ super('logFileChangesWriter', // name for debug
41
+ createWriteStream(log, { flags: 'a' }), // log file write stream (append mode)
42
+ () => {
43
+ debug('finished streaming database changes');
44
+ return ':changes_complete ' + lastSeq + '\n';
45
+ }, // Function to write complete last chunk
46
+ mapBackupBatchToPendingLogLine, // map the changes batch to a log line
47
+ eeFn // postWrite function to emit the 'batch' event
48
+ );
49
49
  }
50
- };
50
+ }
51
51
 
52
- // called once per received change
53
- const onChange = function(c) {
54
- if (c) {
55
- if (c.error) {
56
- ee.emit('error', new error.BackupError('InvalidChange', `Received invalid change: ${c}`));
57
- } else if (c.changes) {
58
- const obj = { id: c.id };
59
- buffer.push(obj);
60
- processBuffer(false);
61
- } else if (c.last_seq) {
62
- lastSeq = c.last_seq;
63
- pending = c.pending;
52
+ // Map a batch of changes to document IDs, checking for errors
53
+ const mapChangesToIds = function(changesBatch) {
54
+ return changesBatch.map((changeResultItem) => {
55
+ if (changeResultItem.changes && changeResultItem.changes.length > 0) {
56
+ if (changeResultItem.seq) {
57
+ lastSeq = changeResultItem.seq;
58
+ }
59
+ // Extract the document ID from the change
60
+ return { id: changeResultItem.id };
61
+ } else {
62
+ throw new BackupError('SpoolChangesError', `Received invalid change: ${JSON.stringify(changeResultItem)}`);
64
63
  }
65
- }
64
+ });
66
65
  };
67
66
 
68
- function getChanges(since = 0) {
69
- debug('making changes request since ' + since);
70
- return db.service.postChangesAsStream({ db: db.db, since: since, limit: limit, seqInterval: limit })
71
- .then(response => {
72
- response.result.pipe(liner())
73
- .on('error', function(err) {
74
- logStream.end();
75
- callback(err);
76
- })
77
- .pipe(change(onChange))
78
- .on('error', function(err) {
79
- logStream.end();
80
- callback(err);
81
- })
82
- .on('finish', function() {
83
- processBuffer(true);
84
- if (!lastSeq) {
85
- logStream.end();
86
- debug('changes request terminated before last_seq was sent');
87
- callback(new error.BackupError('SpoolChangesError', 'Changes request terminated before last_seq was sent'));
88
- } else {
89
- debug(`changes request completed with last_seq: ${lastSeq} and ${pending} changes pending.`);
90
- if (pending > 0) {
91
- // Return the next promise
92
- return getChanges(lastSeq);
93
- } else {
94
- debug('finished streaming database changes');
95
- logStream.end(':changes_complete ' + lastSeq + '\n', 'utf8', callback);
96
- }
97
- }
98
- });
99
- })
100
- .catch(err => {
101
- logStream.end();
102
- if (err.status && err.status >= 400) {
103
- callback(error.convertResponseError(err));
104
- } else if (err.name !== 'SpoolChangesError') {
105
- callback(new error.BackupError('SpoolChangesError', `Failed changes request - ${err.message}`));
106
- }
107
- });
108
- }
67
+ const mapChangesBatchToBackupBatch = function(changesBatch) {
68
+ return { command: 't', batch: batch++, docs: mapChangesToIds(changesBatch) };
69
+ };
70
+
71
+ const mapBackupBatchToPendingLogLine = function(backupBatch) {
72
+ totalBuffer += backupBatch.docs.length;
73
+ debug('writing', backupBatch.docs.length, 'changes to the backup log file with total of', totalBuffer);
74
+ return `:t batch${backupBatch.batch} ${JSON.stringify(backupBatch.docs)}\n`;
75
+ };
76
+
77
+ const changesParams = {
78
+ db: dbClient.dbName,
79
+ seqInterval: bufferSize
80
+ };
109
81
 
110
- getChanges();
82
+ const changesFollower = new ChangesFollower(dbClient.service, changesParams, tolerance);
83
+ return pipeline(
84
+ changesFollower.startOneOff(), // stream of changes from the DB
85
+ new BatchingStream(bufferSize), // group changes into bufferSize batches for mapping
86
+ new MappingStream(mapChangesBatchToBackupBatch), // map a batch of ChangesResultItem to doc IDs
87
+ new LogWriter(log)
88
+ );
111
89
  };
@@ -0,0 +1,378 @@
1
+ // Copyright © 2023, 2024 IBM Corp. All rights reserved.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ 'use strict';
15
+
16
+ const { Duplex, PassThrough, Writable, getDefaultHighWaterMark, setDefaultHighWaterMark } = require('node:stream');
17
+ const debug = require('debug');
18
+
19
+ /**
20
+ * Input: stream of elements
21
+ * Output: stream of arrays of batchSize elements each
22
+ */
23
+ class BatchingStream extends Duplex {
24
+ // The buffer of elements to batch
25
+ elementsToBatch = [];
26
+ // The current batch ID
27
+ batchId = 0;
28
+ // Flag whether the Readable side is currently draining
29
+ isReadableDraining = true;
30
+ // Flag whether the writable side is complete
31
+ isWritableComplete = false;
32
+
33
+ /**
34
+ * Make a new BatchingStream with the given output batch
35
+ * size and whether it is accepting arrays for rebatching
36
+ * or single elements and buffering the set number of batches.
37
+ *
38
+ * @param {number} batchSize output batch (array) size
39
+ * @param {boolean} rebatch true to accept arrays and resize them (defaults to false to accept single items)
40
+ * @param {number} batchHighWaterMark the number of batches to buffer before applying upstream back-pressure
41
+ */
42
+ constructor(batchSize, rebatch = false, batchHighWaterMark = 1) {
43
+ // This Duplex stream is always objectMode and doesn't use the stream
44
+ // buffers. It does use an internal buffer of elements for batching, which
45
+ // holds up to 1 batch in memory.
46
+ // The Writable side of this Duplex is written to by the element supplier.
47
+ // The Readable side of this Duplex is read by the batch consumer.
48
+ super({ objectMode: true, readableHighWaterMark: 0, writableHighWaterMark: 0 });
49
+ // Logging config
50
+ this.log = debug((`couchbackup:transform:${rebatch ? 're' : ''}batch`));
51
+ this.log(`Batching to size ${batchSize}`);
52
+ this.batchSize = batchSize;
53
+ this.rebatch = rebatch;
54
+ this.elementHighWaterMark = batchHighWaterMark * this.batchSize;
55
+ }
56
+
57
+ /**
58
+ * Check the available elementsToBatch and if the downstream consumer is
59
+ * accepting make and push as many batches as possible.
60
+ *
61
+ * This will not push if the Readable is not draining (downstream back-pressure).
62
+ * Batches will be pushed if:
63
+ * 1. The Readable is draining and there are at least batch size elements
64
+ * 2. The Readable is draining and there will be no new elements (the Writable is complete)
65
+ * and there are any elements available.
66
+ * Condition 2 allows for a smaller sized partial final batch.
67
+ */
68
+ tryPushingBatches() {
69
+ this.log('Try to push batches.',
70
+ `Available elements:${this.elementsToBatch.length}`,
71
+ `Readable draining:${this.isReadableDraining}`,
72
+ `Writable complete:${this.isWritableComplete}`);
73
+ while (this.isReadableDraining &&
74
+ (this.elementsToBatch.length >= this.batchSize ||
75
+ (this.isWritableComplete && this.elementsToBatch.length > 0))) {
76
+ // Splice up to batchSize elements from the available elements
77
+ const batch = this.elementsToBatch.splice(0, this.batchSize);
78
+ this.log(`Writing batch ${this.batchId} with ${batch.length} elements.`);
79
+ // Increment the batch ID ready for the next batch
80
+ this.batchId++;
81
+ // push the batch downstream
82
+ if (!this.push(batch)) {
83
+ // There was back-pressure from downstream.
84
+ // Unset the draining flag and break the loop.
85
+ this.isReadableDraining = false;
86
+ break;
87
+ }
88
+ }
89
+ if (this.elementsToBatch.length < this.batchSize) {
90
+ // We've drained the buffer, release upstream.
91
+ if (this.pendingCallback) {
92
+ this.log('Unblocking after downstream reads.');
93
+ this.pendingCallback();
94
+ this.pendingCallback = null;
95
+ }
96
+ }
97
+ if (this.elementsToBatch.length === 0 && this.isWritableComplete) {
98
+ this.log('No further elements, signalling EOF.');
99
+ this.push(null);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Implementation of _read.
105
+ * The Duplex.read is called when downstream can accept more data.
106
+ * That in turn calls this _read implementation.
107
+ *
108
+ * @param {number} size ignored for objectMode
109
+ */
110
+ _read(size) {
111
+ // Downstream asked for data set the draining flag.
112
+ this.isReadableDraining = true;
113
+ // Push any available batches.
114
+ this.tryPushingBatches();
115
+ }
116
+
117
+ /**
118
+ * Implementation of _write that accepts elements and
119
+ * adds them to an array of elementsToBatch.
120
+ * If the size of elementsToBatch exceeds the configured
121
+ * high water mark then the element supplier receives back-pressure
122
+ * via a delayed callback.
123
+ *
124
+ * @param {*} element the element to add to a batch
125
+ * @param {string} encoding ignored (objects are passed as-is)
126
+ * @param {function} callback called back when elementsToBatch is not too full
127
+ */
128
+ _write(element, encoding, callback) {
129
+ if (!this.rebatch) {
130
+ // If we're not rebatching we're dealing with a single element
131
+ // but the push is cleaner if we can spread, so wrap in an array.
132
+ element = [element];
133
+ }
134
+ if (this.elementsToBatch.push(...element) >= this.elementHighWaterMark) {
135
+ // Delay callback as we have more than 1 batch buffered
136
+ // Downstream cannot accept more batches, delay the
137
+ // callback to back-pressure our element supplier until
138
+ // after the next downstream read.
139
+ this.log(`Back pressure after batch ${this.batchId}`);
140
+ this.pendingCallback = callback;
141
+ // If there are enough elements we must try to push batches
142
+ // to satisfy the Readable contract which will not call read
143
+ // again until after a push in the event that no data was available.
144
+ this.tryPushingBatches();
145
+ } else {
146
+ // Callback immediately if there are fewer elements
147
+ callback();
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Implementation of _final to ensure that any partial batches
153
+ * remaining when the supplying stream ends are pushed downstream.
154
+ *
155
+ * @param {function} callback
156
+ */
157
+ _final(callback) {
158
+ this.log('Flushing batch transform.');
159
+ // Set the writable complete flag
160
+ this.isWritableComplete = true;
161
+ // Try to push batches
162
+ this.tryPushingBatches();
163
+ callback();
164
+ }
165
+ }
166
+
167
+ class DelegateWritable extends Writable {
168
+ /**
169
+ * A Writable that delegates to another writable wrapping it in some
170
+ * helpful operations and handling "ending" of special streams like
171
+ * process.stdout.
172
+ *
173
+ * @param {string} name - the name of this DelegateWritable for logging
174
+ * @param {Writable} targetWritable - the Writable stream to write to
175
+ * @param {function} lastChunkFn - a no-args function to call to get a final chunk to write
176
+ * @param {function} chunkMapFn - a function(chunk) that can transform/map a chunk before writing
177
+ * @param {function} postWriteFn - a function(chunk) that can perform an action after a write completes
178
+ */
179
+ constructor(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn) {
180
+ super({ objectMode: true });
181
+ this.name = name;
182
+ this.targetWritable = targetWritable;
183
+ this.lastChunkFn = lastChunkFn;
184
+ this.chunkMapFn = chunkMapFn;
185
+ this.postWriteFn = postWriteFn;
186
+ this.log = debug((`couchbackup:transform:delegate:${name}`));
187
+ }
188
+
189
+ _write(chunk, encoding, callback) {
190
+ const toWrite = (this.chunkMapFn) ? this.chunkMapFn(chunk) : chunk;
191
+ this.targetWritable.write(toWrite, encoding, (err) => {
192
+ if (!err) {
193
+ this.log('completed target chunk write');
194
+ if (this.postWriteFn) {
195
+ this.postWriteFn(chunk);
196
+ }
197
+ }
198
+ callback(err);
199
+ });
200
+ }
201
+
202
+ _final(callback) {
203
+ this.log('Finalizing');
204
+ const lastChunk = (this.lastChunkFn && this.lastChunkFn()) || null;
205
+ // We can't 'end' stdout, so use a final write instead for that case
206
+ if (this.targetWritable === process.stdout) {
207
+ // we can't 'write' null, so don't do anything if there is no last chunk
208
+ if (lastChunk) {
209
+ this.targetWritable.write(lastChunk, 'utf-8', (err) => {
210
+ if (!err) {
211
+ this.log('wrote last chunk to stdout');
212
+ } else {
213
+ this.log('error writing last chunk to stdout');
214
+ }
215
+ callback(err);
216
+ });
217
+ } else {
218
+ this.log('no last chunk to write to stdout');
219
+ callback();
220
+ }
221
+ } else {
222
+ this.targetWritable.end(lastChunk, 'utf-8', (err) => {
223
+ if (!err) {
224
+ this.log('wrote last chunk and ended target writable');
225
+ } else {
226
+ this.log('error ending target writable');
227
+ }
228
+ callback(err);
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * A helper PassThrough class that is used in our custom
236
+ * Duplex streams.
237
+ */
238
+ class DuplexPassThrough extends PassThrough {
239
+ constructor(opts) {
240
+ super({ objectMode: true, readableHighWaterMark: 0, writableHighWaterMark: 0, ...opts });
241
+ }
242
+
243
+ // The destroy call on this PassThrough stream
244
+ // gets ahead of real errors reaching the
245
+ // callback/promise at the end of the pipeline.
246
+ // This allows an AbortError to get propagated
247
+ // from the micro-task queue instead because the
248
+ // real error is blocked behind a callback somewhere.
249
+ // That in turn masks the real cause of the failure,
250
+ // so we defer the _destroy in a setImmediate.
251
+ _destroy(err, cb) {
252
+ setImmediate(() => {
253
+ cb(err);
254
+ });
255
+ }
256
+ }
257
+
258
+ class DuplexMapper extends Duplex {
259
+ constructor(fn, style, concurrency = 1) {
260
+ const operatorOpts = { concurrency, highWaterMark: concurrency };
261
+ const inputStream = new DuplexPassThrough();
262
+ let outputStream;
263
+ switch (style) {
264
+ case 'map':
265
+ outputStream = inputStream.map(fn, operatorOpts);
266
+ break;
267
+ case 'filter':
268
+ outputStream = inputStream.filter(fn, operatorOpts);
269
+ break;
270
+ default:
271
+ throw new Error('Invalid style.');
272
+ }
273
+ // Workaround the fact that Duplex.from doesn't allow customizing the HWM
274
+ // Set a new objectMode default value while we create the stream, then reset it.
275
+ const originalHWM = getDefaultHighWaterMark(true);
276
+ // Use concurrency as the highWaterMark to allow one item on deck for each "thread"
277
+ setDefaultHighWaterMark(true, concurrency);
278
+ try {
279
+ return Duplex.from({ readable: outputStream, writable: inputStream });
280
+ } finally {
281
+ setDefaultHighWaterMark(true, originalHWM);
282
+ }
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Input: stream of x
288
+ * Output: stream of x with elements not passing the filter removed
289
+ */
290
+ class FilterStream extends DuplexMapper {
291
+ constructor(filterFunction) {
292
+ super(filterFunction, 'filter');
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Input: stream of x
298
+ * Output: stream of mappingFunction(x)
299
+ */
300
+ class MappingStream extends DuplexMapper {
301
+ constructor(mappingFunction, concurrency = 1) {
302
+ super(mappingFunction, 'map', concurrency);
303
+ }
304
+ }
305
+
306
+ /**
307
+ * PassThrough stream that calls another function
308
+ * to perform a side effect.
309
+ */
310
+ class SideEffect extends DuplexPassThrough {
311
+ constructor(fn, options) {
312
+ super(options);
313
+ this.fn = fn;
314
+ this.log = debug(('couchbackup:transform:sideeffect'));
315
+ }
316
+
317
+ async doSideEffect(chunk, encoding) {
318
+ return await this.fn(chunk, encoding);
319
+ }
320
+
321
+ _transform(chunk, encoding, callback) {
322
+ this.log('Performing side effect');
323
+ this.doSideEffect(chunk, encoding)
324
+ .then(() => {
325
+ this.log('Passing through');
326
+ super._transform(chunk, encoding, callback);
327
+ }).catch((err) => {
328
+ this.log(`Caught error ${err}`);
329
+ callback(err);
330
+ });
331
+ }
332
+ }
333
+
334
+ class WritableWithPassThrough extends SideEffect {
335
+ /**
336
+ * A Writable that passes through the original chunk.
337
+ * The chunk is also passed to a SideEffect which behaves as DelegateWritable does.
338
+ *
339
+ * @param {string} name - the name of the DelegateWritable for logging
340
+ * @param {Writable} targetWritable - the Writable stream to write to
341
+ * @param {function} lastChunkFn - a no-args function to call to get a final chunk to write
342
+ * @param {function} chunkMapFn - a function(chunk) that can transform/map a chunk before writing
343
+ * @param {function} postWriteFn - a function(chunk) that can perform an action after a write completes
344
+ */
345
+ constructor(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn) {
346
+ // Initialize super without a side effect fn because we need to set some
347
+ // properties before we can define it.
348
+ super(null, { objectMode: true });
349
+ this.log = debug(`couchbackup:transform:writablepassthrough:${name}`);
350
+ this.delegateWritable = new DelegateWritable(name, targetWritable, lastChunkFn, chunkMapFn, postWriteFn);
351
+ // Now set the side effect fn we omitted earlier
352
+ this.fn = (chunk, encoding) => {
353
+ return new Promise((resolve, reject) => {
354
+ this.delegateWritable.write(chunk, encoding, (err) => {
355
+ if (err) {
356
+ reject(err);
357
+ } else {
358
+ resolve();
359
+ }
360
+ });
361
+ });
362
+ };
363
+ }
364
+
365
+ _flush(callback) {
366
+ this.log('Flushing writable passthrough');
367
+ this.delegateWritable.end(callback);
368
+ }
369
+ }
370
+
371
+ module.exports = {
372
+ BatchingStream,
373
+ DelegateWritable,
374
+ FilterStream,
375
+ MappingStream,
376
+ SideEffect,
377
+ WritableWithPassThrough
378
+ };