@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.
- package/README.md +86 -33
- package/package.json +3 -3
- package/src/cli.ts +82 -620
- package/src/config/defaults.ts +4 -0
- package/src/config/parser.ts +4 -0
- package/src/config/validator.ts +52 -0
- package/src/firebase/index.ts +82 -0
- package/src/interactive.ts +59 -56
- package/src/orchestrator.ts +407 -0
- package/src/output/display.ts +221 -0
- package/src/state/index.ts +188 -1
- package/src/transfer/clear.ts +162 -104
- package/src/transfer/count.ts +83 -44
- package/src/transfer/transfer.ts +487 -156
- package/src/transform/loader.ts +31 -0
- package/src/types.ts +18 -0
- package/src/utils/credentials.ts +9 -4
- package/src/utils/doc-size.ts +41 -70
- package/src/utils/errors.ts +1 -1
- package/src/utils/index.ts +2 -1
- package/src/utils/integrity.ts +122 -0
- package/src/utils/logger.ts +59 -3
- package/src/utils/output.ts +265 -0
- package/src/utils/patterns.ts +3 -2
- package/src/utils/progress.ts +102 -0
- package/src/utils/rate-limiter.ts +4 -2
- package/src/webhook/index.ts +24 -6
|
@@ -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
|
+
}
|