@fazetitans/fscopy 1.1.2 → 1.2.0

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,407 @@
1
+ import type { Firestore } from 'firebase-admin/firestore';
2
+
3
+ import type { Config, Stats, TransferState, TransformFunction, CliArgs, ConflictInfo } from './types.js';
4
+ import { Output } from './utils/output.js';
5
+ import { RateLimiter } from './utils/rate-limiter.js';
6
+ import { ProgressBarWrapper } from './utils/progress.js';
7
+ import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState, StateSaver } from './state/index.js';
8
+ import { sendWebhook } from './webhook/index.js';
9
+ import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress } from './transfer/index.js';
10
+ import { initializeFirebase, checkDatabaseConnectivity, cleanupFirebase } from './firebase/index.js';
11
+ import { loadTransformFunction } from './transform/loader.js';
12
+ import { printSummary, formatJsonOutput } from './output/display.js';
13
+
14
+ export interface TransferResult {
15
+ success: boolean;
16
+ stats: Stats;
17
+ duration: number;
18
+ error?: string;
19
+ verifyResult?: Record<string, { source: number; dest: number; match: boolean }> | null;
20
+ }
21
+
22
+ interface ResumeResult {
23
+ state: TransferState | null;
24
+ stats: Stats;
25
+ }
26
+
27
+ function initializeResumeMode(config: Config, output: Output): ResumeResult {
28
+ if (config.resume) {
29
+ const existingState = loadTransferState(config.stateFile);
30
+ if (!existingState) {
31
+ throw new Error(`No state file found at ${config.stateFile}. Cannot resume without a saved state. Run without --resume to start fresh.`);
32
+ }
33
+
34
+ const stateErrors = validateStateForResume(existingState, config);
35
+ if (stateErrors.length > 0) {
36
+ throw new Error(`Cannot resume: state file incompatible with current config:\n - ${stateErrors.join('\n - ')}`);
37
+ }
38
+
39
+ const completedCount = Object.values(existingState.completedDocs).reduce((sum, ids) => sum + ids.length, 0);
40
+ output.info(`\nšŸ”„ Resuming transfer from ${config.stateFile}`);
41
+ output.info(` Started: ${existingState.startedAt}`);
42
+ output.info(` Previously completed: ${completedCount} documents`);
43
+
44
+ return { state: existingState, stats: { ...existingState.stats } };
45
+ }
46
+
47
+ if (!config.dryRun) {
48
+ const newState = createInitialState(config);
49
+ saveTransferState(config.stateFile, newState);
50
+ output.info(`\nšŸ’¾ State will be saved to ${config.stateFile} (use --resume to continue if interrupted)`);
51
+ return { state: newState, stats: createEmptyStats() };
52
+ }
53
+
54
+ return { state: null, stats: createEmptyStats() };
55
+ }
56
+
57
+ function createEmptyStats(): Stats {
58
+ return { collectionsProcessed: 0, documentsTransferred: 0, documentsDeleted: 0, errors: 0, conflicts: 0, integrityErrors: 0 };
59
+ }
60
+
61
+ async function loadTransform(config: Config, output: Output): Promise<TransformFunction | null> {
62
+ if (!config.transform) return null;
63
+
64
+ output.info(`\nšŸ”§ Loading transform: ${config.transform}`);
65
+ const transformFn = await loadTransformFunction(config.transform);
66
+ output.info(' Transform loaded successfully');
67
+ return transformFn;
68
+ }
69
+
70
+ async function handleSuccessOutput(
71
+ config: Config,
72
+ argv: CliArgs,
73
+ stats: Stats,
74
+ duration: number,
75
+ verifyResult: Record<string, { source: number; dest: number; match: boolean }> | null,
76
+ output: Output
77
+ ): Promise<void> {
78
+ if (config.json) {
79
+ output.json(JSON.parse(formatJsonOutput(true, config, stats, duration, undefined, verifyResult)));
80
+ } else {
81
+ printSummary(stats, duration.toFixed(2), argv.log, config.dryRun);
82
+ }
83
+
84
+ if (config.webhook) {
85
+ await sendWebhook(config.webhook, {
86
+ source: config.sourceProject!,
87
+ destination: config.destProject!,
88
+ collections: config.collections,
89
+ stats,
90
+ duration,
91
+ dryRun: config.dryRun,
92
+ success: true,
93
+ }, output);
94
+ }
95
+ }
96
+
97
+ async function handleErrorOutput(
98
+ config: Config,
99
+ stats: Stats,
100
+ duration: number,
101
+ errorMessage: string,
102
+ output: Output
103
+ ): Promise<void> {
104
+ if (config.json) {
105
+ output.json(JSON.parse(formatJsonOutput(false, config, stats, duration, errorMessage)));
106
+ } else {
107
+ output.error(`\nāŒ Error during transfer: ${errorMessage}`);
108
+ }
109
+
110
+ if (config.webhook) {
111
+ await sendWebhook(config.webhook, {
112
+ source: config.sourceProject ?? 'unknown',
113
+ destination: config.destProject ?? 'unknown',
114
+ collections: config.collections,
115
+ stats,
116
+ duration,
117
+ dryRun: config.dryRun,
118
+ success: false,
119
+ error: errorMessage,
120
+ }, output);
121
+ }
122
+ }
123
+
124
+ export async function runTransfer(config: Config, argv: CliArgs, output: Output): Promise<TransferResult> {
125
+ const startTime = Date.now();
126
+
127
+ try {
128
+ const { state: transferState, stats } = initializeResumeMode(config, output);
129
+ const transformFn = await loadTransform(config, output);
130
+
131
+ output.blank();
132
+ const { sourceDb, destDb } = initializeFirebase(config);
133
+ await checkDatabaseConnectivity(sourceDb, destDb, config, output);
134
+
135
+ if (transformFn && config.dryRun && config.transformSamples !== 0) {
136
+ await validateTransformWithSamples(sourceDb, config, transformFn, output);
137
+ }
138
+
139
+ const currentStats = config.resume ? stats : createEmptyStats();
140
+ const { progressBar } = await setupProgressTracking(sourceDb, config, currentStats, output);
141
+
142
+ if (config.clear) {
143
+ await clearDestinationCollections(destDb, config, currentStats, output);
144
+ }
145
+
146
+ const rateLimiter = config.rateLimit > 0 ? new RateLimiter(config.rateLimit) : null;
147
+ if (rateLimiter) {
148
+ output.info(`ā±ļø Rate limiting enabled: ${config.rateLimit} docs/s\n`);
149
+ }
150
+
151
+ const stateSaver = transferState ? new StateSaver(config.stateFile, transferState) : null;
152
+
153
+ const conflictList: ConflictInfo[] = [];
154
+ const ctx: TransferContext = {
155
+ sourceDb, destDb, config, stats: currentStats, output, progressBar, transformFn, stateSaver, rateLimiter, conflictList
156
+ };
157
+
158
+ await executeTransfer(ctx, output);
159
+ stateSaver?.flush();
160
+ cleanupProgressBar(progressBar);
161
+
162
+ if (config.deleteMissing) {
163
+ await deleteOrphanDocs(sourceDb, destDb, config, currentStats, output);
164
+ }
165
+
166
+ const duration = (Date.now() - startTime) / 1000;
167
+ output.logSuccess('Transfer completed', { stats: currentStats as unknown as Record<string, unknown>, duration: duration.toFixed(2) });
168
+ output.logSummary(currentStats, duration.toFixed(2));
169
+
170
+ const verifyResult = config.verify && !config.dryRun
171
+ ? await verifyTransfer(sourceDb, destDb, config, output)
172
+ : null;
173
+
174
+ if (!config.dryRun) {
175
+ deleteTransferState(config.stateFile);
176
+ }
177
+
178
+ await handleSuccessOutput(config, argv, currentStats, duration, verifyResult, output);
179
+ await cleanupFirebase();
180
+
181
+ return { success: true, stats: currentStats, duration, verifyResult };
182
+ } catch (error) {
183
+ const errorMessage = (error as Error).message;
184
+ const duration = (Date.now() - startTime) / 1000;
185
+
186
+ await handleErrorOutput(config, createEmptyStats(), duration, errorMessage, output);
187
+ await cleanupFirebase();
188
+
189
+ return { success: false, stats: createEmptyStats(), duration, error: errorMessage };
190
+ }
191
+ }
192
+
193
+ // =============================================================================
194
+ // Helper Functions
195
+ // =============================================================================
196
+
197
+ async function validateTransformWithSamples(
198
+ sourceDb: Firestore,
199
+ config: Config,
200
+ transformFn: TransformFunction,
201
+ output: Output
202
+ ): Promise<void> {
203
+ const samplesPerCollection = config.transformSamples;
204
+ const testAll = samplesPerCollection < 0;
205
+
206
+ output.info(`🧪 Validating transform with ${testAll ? 'all' : samplesPerCollection} sample(s) per collection...`);
207
+ let samplesTested = 0;
208
+ let samplesSkipped = 0;
209
+ let samplesErrors = 0;
210
+
211
+ for (const collection of config.collections) {
212
+ let query: FirebaseFirestore.Query = sourceDb.collection(collection);
213
+ if (!testAll) {
214
+ query = query.limit(samplesPerCollection);
215
+ }
216
+
217
+ const snapshot = await query.get();
218
+ for (const doc of snapshot.docs) {
219
+ try {
220
+ const result = transformFn(doc.data() as Record<string, unknown>, {
221
+ id: doc.id,
222
+ path: `${collection}/${doc.id}`,
223
+ });
224
+ if (result === null) {
225
+ samplesSkipped++;
226
+ } else {
227
+ samplesTested++;
228
+ }
229
+ } catch (error) {
230
+ samplesErrors++;
231
+ const err = error as Error;
232
+ output.error(` āš ļø Transform error on ${collection}/${doc.id}: ${err.message}`);
233
+ }
234
+ }
235
+ }
236
+
237
+ if (samplesErrors > 0) {
238
+ output.info(` āŒ ${samplesErrors} sample(s) failed - review your transform function`);
239
+ } else if (samplesTested > 0 || samplesSkipped > 0) {
240
+ output.info(` āœ“ Tested ${samplesTested} sample(s), ${samplesSkipped} would be skipped`);
241
+ }
242
+ output.blank();
243
+ }
244
+
245
+ async function setupProgressTracking(
246
+ sourceDb: Firestore,
247
+ config: Config,
248
+ stats: Stats,
249
+ output: Output
250
+ ): Promise<{ totalDocs: number; progressBar: ProgressBarWrapper }> {
251
+ let totalDocs = 0;
252
+ const progressBar = new ProgressBarWrapper();
253
+
254
+ if (!output.isQuiet) {
255
+ output.info('šŸ“Š Counting documents...');
256
+ let lastSubcollectionLog = Date.now();
257
+ let subcollectionCount = 0;
258
+
259
+ const countProgress: CountProgress = {
260
+ onCollection: (path, count) => {
261
+ output.info(` ${path}: ${count} documents`);
262
+ },
263
+ onSubcollection: (_path) => {
264
+ subcollectionCount++;
265
+ const now = Date.now();
266
+ if (now - lastSubcollectionLog > 2000) {
267
+ process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
268
+ lastSubcollectionLog = now;
269
+ }
270
+ },
271
+ };
272
+
273
+ for (const collection of config.collections) {
274
+ totalDocs += await countDocuments(sourceDb, collection, config, 0, countProgress);
275
+ }
276
+
277
+ if (subcollectionCount > 0) {
278
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
279
+ output.info(` Subcollections scanned: ${subcollectionCount}`);
280
+ }
281
+ output.info(` Total: ${totalDocs} documents to transfer\n`);
282
+
283
+ progressBar.start(totalDocs, stats);
284
+ }
285
+
286
+ return { totalDocs, progressBar };
287
+ }
288
+
289
+ async function clearDestinationCollections(
290
+ destDb: Firestore,
291
+ config: Config,
292
+ stats: Stats,
293
+ output: Output
294
+ ): Promise<void> {
295
+ output.info('šŸ—‘ļø Clearing destination collections...');
296
+ for (const collection of config.collections) {
297
+ const destCollection = getDestCollectionPath(collection, config.renameCollection);
298
+ const deleted = await clearCollection(
299
+ destDb,
300
+ destCollection,
301
+ config,
302
+ output,
303
+ config.includeSubcollections
304
+ );
305
+ stats.documentsDeleted += deleted;
306
+ }
307
+ output.info(` Deleted ${stats.documentsDeleted} documents\n`);
308
+ }
309
+
310
+ async function executeParallelTransfer(ctx: TransferContext, output: Output): Promise<void> {
311
+ const { errors } = await processInParallel(ctx.config.collections, ctx.config.parallel, (collection) =>
312
+ transferCollection(ctx, collection)
313
+ );
314
+ for (const err of errors) {
315
+ output.logError('Parallel transfer error', { error: err.message });
316
+ ctx.stats.errors++;
317
+ }
318
+ }
319
+
320
+ async function executeSequentialTransfer(ctx: TransferContext, output: Output): Promise<void> {
321
+ for (const collection of ctx.config.collections) {
322
+ try {
323
+ await transferCollection(ctx, collection);
324
+ } catch (error) {
325
+ const err = error instanceof Error ? error : new Error(String(error));
326
+ output.logError(`Transfer failed for ${collection}`, { error: err.message });
327
+ ctx.stats.errors++;
328
+ }
329
+ }
330
+ }
331
+
332
+ async function executeTransfer(ctx: TransferContext, output: Output): Promise<void> {
333
+ if (ctx.config.parallel > 1) {
334
+ await executeParallelTransfer(ctx, output);
335
+ } else {
336
+ await executeSequentialTransfer(ctx, output);
337
+ }
338
+ }
339
+
340
+ function cleanupProgressBar(progressBar: ProgressBarWrapper): void {
341
+ progressBar.stop();
342
+ }
343
+
344
+ async function deleteOrphanDocs(
345
+ sourceDb: Firestore,
346
+ destDb: Firestore,
347
+ config: Config,
348
+ stats: Stats,
349
+ output: Output
350
+ ): Promise<void> {
351
+ output.info('\nšŸ”„ Deleting orphan documents (sync mode)...');
352
+ for (const collection of config.collections) {
353
+ const deleted = await deleteOrphanDocuments(
354
+ sourceDb,
355
+ destDb,
356
+ collection,
357
+ config,
358
+ output
359
+ );
360
+ stats.documentsDeleted += deleted;
361
+ }
362
+ if (stats.documentsDeleted > 0) {
363
+ output.info(` Deleted ${stats.documentsDeleted} orphan documents`);
364
+ } else {
365
+ output.info(' No orphan documents found');
366
+ }
367
+ }
368
+
369
+ async function verifyTransfer(
370
+ sourceDb: Firestore,
371
+ destDb: Firestore,
372
+ config: Config,
373
+ output: Output
374
+ ): Promise<Record<string, { source: number; dest: number; match: boolean }>> {
375
+ output.info('\nšŸ” Verifying transfer...');
376
+
377
+ const verifyResult: Record<string, { source: number; dest: number; match: boolean }> = {};
378
+ let verifyPassed = true;
379
+
380
+ for (const collection of config.collections) {
381
+ const destCollection = getDestCollectionPath(collection, config.renameCollection);
382
+
383
+ const sourceCount = await sourceDb.collection(collection).count().get();
384
+ const sourceTotal = sourceCount.data().count;
385
+
386
+ const destCount = await destDb.collection(destCollection).count().get();
387
+ const destTotal = destCount.data().count;
388
+
389
+ const match = sourceTotal === destTotal;
390
+ verifyResult[collection] = { source: sourceTotal, dest: destTotal, match };
391
+
392
+ if (match) {
393
+ output.info(` āœ“ ${collection}: ${sourceTotal} docs (matched)`);
394
+ } else {
395
+ output.info(` āš ļø ${collection}: source=${sourceTotal}, dest=${destTotal} (mismatch)`);
396
+ }
397
+ if (!match) verifyPassed = false;
398
+ }
399
+
400
+ if (verifyPassed) {
401
+ output.info(' āœ“ Verification passed');
402
+ } else {
403
+ output.info(' āš ļø Verification found mismatches');
404
+ }
405
+
406
+ return verifyResult;
407
+ }
@@ -0,0 +1,221 @@
1
+ import readline from 'node:readline';
2
+ import type { Config, Stats } from '../types.js';
3
+
4
+ function formatIdModification(config: Config): string | null {
5
+ if (!config.idPrefix && !config.idSuffix) return null;
6
+ const parts = [
7
+ config.idPrefix ? `prefix: "${config.idPrefix}"` : null,
8
+ config.idSuffix ? `suffix: "${config.idSuffix}"` : null,
9
+ ].filter(Boolean);
10
+ return parts.join(', ');
11
+ }
12
+
13
+ function formatRenameCollections(config: Config): string | null {
14
+ if (Object.keys(config.renameCollection).length === 0) return null;
15
+ return Object.entries(config.renameCollection)
16
+ .map(([src, dest]) => `${src}→${dest}`)
17
+ .join(', ');
18
+ }
19
+
20
+ function displayAdditionalOptions(config: Config): void {
21
+ const options: Array<{ condition: boolean; icon: string; label: string; value: string }> = [
22
+ {
23
+ condition: config.where.length > 0,
24
+ icon: 'šŸ”',
25
+ label: 'Where filters',
26
+ value: config.where.map((w) => `${w.field} ${w.operator} ${w.value}`).join(', '),
27
+ },
28
+ {
29
+ condition: config.exclude.length > 0,
30
+ icon: '🚫',
31
+ label: 'Exclude patterns',
32
+ value: config.exclude.join(', '),
33
+ },
34
+ {
35
+ condition: config.merge,
36
+ icon: 'šŸ”€',
37
+ label: 'Merge mode',
38
+ value: 'enabled (merge instead of overwrite)',
39
+ },
40
+ {
41
+ condition: config.parallel > 1,
42
+ icon: '⚔',
43
+ label: 'Parallel transfers',
44
+ value: `${config.parallel} collections`,
45
+ },
46
+ {
47
+ condition: config.clear,
48
+ icon: 'šŸ—‘ļø ',
49
+ label: 'Clear destination',
50
+ value: 'enabled (DESTRUCTIVE)',
51
+ },
52
+ {
53
+ condition: config.deleteMissing,
54
+ icon: 'šŸ”„',
55
+ label: 'Delete missing',
56
+ value: 'enabled (sync mode)',
57
+ },
58
+ {
59
+ condition: Boolean(config.transform),
60
+ icon: 'šŸ”§',
61
+ label: 'Transform',
62
+ value: config.transform ?? '',
63
+ },
64
+ {
65
+ condition: Boolean(formatRenameCollections(config)),
66
+ icon: 'šŸ“',
67
+ label: 'Rename collections',
68
+ value: formatRenameCollections(config) ?? '',
69
+ },
70
+ {
71
+ condition: Boolean(formatIdModification(config)),
72
+ icon: 'šŸ·ļø ',
73
+ label: 'ID modification',
74
+ value: formatIdModification(config) ?? '',
75
+ },
76
+ {
77
+ condition: config.rateLimit > 0,
78
+ icon: 'ā±ļø ',
79
+ label: 'Rate limit',
80
+ value: `${config.rateLimit} docs/s`,
81
+ },
82
+ {
83
+ condition: config.skipOversized,
84
+ icon: 'šŸ“',
85
+ label: 'Skip oversized',
86
+ value: 'enabled (skip docs > 1MB)',
87
+ },
88
+ {
89
+ condition: config.detectConflicts,
90
+ icon: 'šŸ”’',
91
+ label: 'Detect conflicts',
92
+ value: 'enabled',
93
+ },
94
+ {
95
+ condition: config.maxDepth > 0,
96
+ icon: 'šŸ“Š',
97
+ label: 'Max depth',
98
+ value: `${config.maxDepth} level(s)`,
99
+ },
100
+ {
101
+ condition: config.verifyIntegrity,
102
+ icon: 'āœ…',
103
+ label: 'Verify integrity',
104
+ value: 'enabled (hash verification)',
105
+ },
106
+ ];
107
+
108
+ for (const opt of options) {
109
+ if (opt.condition) {
110
+ console.log(` ${opt.icon} ${opt.label.padEnd(18)} ${opt.value}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ export function displayConfig(config: Config): void {
116
+ console.log('='.repeat(60));
117
+ console.log('šŸ”„ FSCOPY - CONFIGURATION');
118
+ console.log('='.repeat(60));
119
+ console.log('');
120
+ console.log(` šŸ“¤ Source project: ${config.sourceProject || '(not set)'}`);
121
+ console.log(` šŸ“„ Destination project: ${config.destProject || '(not set)'}`);
122
+ console.log('');
123
+ console.log(
124
+ ` šŸ“‹ Collections: ${config.collections.length > 0 ? config.collections.join(', ') : '(none)'}`
125
+ );
126
+ console.log(` šŸ“‚ Include subcollections: ${config.includeSubcollections}`);
127
+ console.log(` šŸ”¢ Document limit: ${config.limit === 0 ? 'No limit' : config.limit}`);
128
+ console.log(` šŸ“¦ Batch size: ${config.batchSize}`);
129
+ console.log(` šŸ”„ Retries on error: ${config.retries}`);
130
+
131
+ displayAdditionalOptions(config);
132
+
133
+ console.log('');
134
+ console.log(
135
+ config.dryRun
136
+ ? ' šŸ” Mode: DRY RUN (no data will be written)'
137
+ : ' ⚔ Mode: LIVE (data WILL be transferred)'
138
+ );
139
+ console.log('');
140
+ console.log('='.repeat(60));
141
+ }
142
+
143
+ export async function askConfirmation(config: Config): Promise<boolean> {
144
+ const rl = readline.createInterface({
145
+ input: process.stdin,
146
+ output: process.stdout,
147
+ });
148
+
149
+ return new Promise((resolve) => {
150
+ const modeText = config.dryRun ? 'DRY RUN' : 'āš ļø LIVE TRANSFER';
151
+ rl.question(`\nProceed with ${modeText}? (y/N): `, (answer) => {
152
+ rl.close();
153
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
154
+ });
155
+ });
156
+ }
157
+
158
+ export function printSummary(
159
+ stats: Stats,
160
+ duration: string,
161
+ logFile?: string,
162
+ dryRun?: boolean
163
+ ): void {
164
+ console.log('\n' + '='.repeat(60));
165
+ console.log('šŸ“Š TRANSFER SUMMARY');
166
+ console.log('='.repeat(60));
167
+ console.log(`Collections processed: ${stats.collectionsProcessed}`);
168
+ if (stats.documentsDeleted > 0) {
169
+ console.log(`Documents deleted: ${stats.documentsDeleted}`);
170
+ }
171
+ console.log(`Documents transferred: ${stats.documentsTransferred}`);
172
+ if (stats.conflicts > 0) {
173
+ console.log(`Conflicts detected: ${stats.conflicts}`);
174
+ }
175
+ if (stats.integrityErrors > 0) {
176
+ console.log(`Integrity errors: ${stats.integrityErrors}`);
177
+ }
178
+ console.log(`Errors: ${stats.errors}`);
179
+ console.log(`Duration: ${duration}s`);
180
+
181
+ if (logFile) {
182
+ console.log(`Log file: ${logFile}`);
183
+ }
184
+
185
+ if (dryRun) {
186
+ console.log('\n⚠ DRY RUN: No data was actually written');
187
+ console.log(' Run with --dry-run=false to perform the transfer');
188
+ } else {
189
+ console.log('\nāœ“ Transfer completed successfully');
190
+ }
191
+ console.log('='.repeat(60) + '\n');
192
+ }
193
+
194
+ export function formatJsonOutput(
195
+ success: boolean,
196
+ config: Config,
197
+ stats: Stats,
198
+ duration: number,
199
+ error?: string,
200
+ verifyResult?: Record<string, { source: number; dest: number; match: boolean }> | null
201
+ ): string {
202
+ const output = {
203
+ success,
204
+ ...(error && { error }),
205
+ dryRun: config.dryRun,
206
+ source: config.sourceProject,
207
+ destination: config.destProject,
208
+ collections: config.collections,
209
+ stats: {
210
+ collectionsProcessed: stats.collectionsProcessed,
211
+ documentsTransferred: stats.documentsTransferred,
212
+ documentsDeleted: stats.documentsDeleted,
213
+ errors: stats.errors,
214
+ conflicts: stats.conflicts,
215
+ integrityErrors: stats.integrityErrors,
216
+ },
217
+ duration,
218
+ ...(verifyResult && { verify: verifyResult }),
219
+ };
220
+ return JSON.stringify(output, null, 2);
221
+ }