@fazetitans/fscopy 1.0.1 → 1.1.1

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.
Files changed (3) hide show
  1. package/README.md +21 -1
  2. package/package.json +1 -1
  3. package/src/cli.ts +298 -1377
package/src/cli.ts CHANGED
@@ -1,116 +1,31 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ // Suppress GCE metadata lookup warning (we're not running on Google Cloud)
4
+ process.env.METADATA_SERVER_DETECTION = 'none';
5
+
3
6
  import admin from 'firebase-admin';
4
- import type { Firestore, DocumentReference, WriteBatch } from 'firebase-admin/firestore';
7
+ import type { Firestore } from 'firebase-admin/firestore';
5
8
  import yargs from 'yargs';
6
9
  import { hideBin } from 'yargs/helpers';
7
- import ini from 'ini';
8
10
  import cliProgress from 'cli-progress';
9
11
  import fs from 'node:fs';
10
12
  import path from 'node:path';
11
13
  import readline from 'node:readline';
12
- import { input, confirm, checkbox } from '@inquirer/prompts';
13
-
14
- // =============================================================================
15
- // Types
16
- // =============================================================================
17
-
18
- interface WhereFilter {
19
- field: string;
20
- operator: FirebaseFirestore.WhereFilterOp;
21
- value: string | number | boolean;
22
- }
23
-
24
- interface Config {
25
- collections: string[];
26
- includeSubcollections: boolean;
27
- dryRun: boolean;
28
- batchSize: number;
29
- limit: number;
30
- sourceProject: string | null;
31
- destProject: string | null;
32
- retries: number;
33
- where: WhereFilter[];
34
- exclude: string[];
35
- merge: boolean;
36
- parallel: number;
37
- clear: boolean;
38
- deleteMissing: boolean;
39
- transform: string | null;
40
- renameCollection: Record<string, string>;
41
- idPrefix: string | null;
42
- idSuffix: string | null;
43
- webhook: string | null;
44
- resume: boolean;
45
- stateFile: string;
46
- }
47
14
 
48
- type TransformFunction = (
49
- doc: Record<string, unknown>,
50
- meta: { id: string; path: string }
51
- ) => Record<string, unknown> | null;
52
-
53
- interface Stats {
54
- collectionsProcessed: number;
55
- documentsTransferred: number;
56
- documentsDeleted: number;
57
- errors: number;
58
- }
59
-
60
- interface TransferState {
61
- version: number;
62
- sourceProject: string;
63
- destProject: string;
64
- collections: string[];
65
- startedAt: string;
66
- updatedAt: string;
67
- completedDocs: Record<string, string[]>; // collectionPath -> array of doc IDs
68
- stats: Stats;
69
- }
70
-
71
- interface LogEntry {
72
- timestamp: string;
73
- level: string;
74
- message: string;
75
- [key: string]: unknown;
76
- }
77
-
78
- interface RetryOptions {
79
- retries?: number;
80
- baseDelay?: number;
81
- maxDelay?: number;
82
- onRetry?: (attempt: number, max: number, error: Error, delay: number) => void;
83
- }
84
-
85
- interface CliArgs {
86
- init?: string;
87
- config?: string;
88
- collections?: string[];
89
- includeSubcollections?: boolean;
90
- dryRun?: boolean;
91
- batchSize?: number;
92
- limit?: number;
93
- sourceProject?: string;
94
- destProject?: string;
95
- yes: boolean;
96
- log?: string;
97
- retries: number;
98
- quiet: boolean;
99
- where?: string[];
100
- exclude?: string[];
101
- merge?: boolean;
102
- parallel?: number;
103
- clear?: boolean;
104
- deleteMissing?: boolean;
105
- interactive?: boolean;
106
- transform?: string;
107
- renameCollection?: string[];
108
- idPrefix?: string;
109
- idSuffix?: string;
110
- webhook?: string;
111
- resume?: boolean;
112
- stateFile?: string;
113
- }
15
+ // Import from modules
16
+ import type { Config, Stats, TransferState, TransformFunction, CliArgs } from './types.js';
17
+ import { Logger } from './utils/logger.js';
18
+ import { ensureCredentials } from './utils/credentials.js';
19
+ import { formatFirebaseError } from './utils/errors.js';
20
+ import { RateLimiter } from './utils/rate-limiter.js';
21
+ import { loadConfigFile, mergeConfig } from './config/parser.js';
22
+ import { validateConfig } from './config/validator.js';
23
+ import { defaults } from './config/defaults.js';
24
+ import { generateConfigFile } from './config/generator.js';
25
+ import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState } from './state/index.js';
26
+ import { sendWebhook } from './webhook/index.js';
27
+ import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress } from './transfer/index.js';
28
+ import { runInteractiveMode } from './interactive.js';
114
29
 
115
30
  // =============================================================================
116
31
  // CLI Arguments
@@ -254,6 +169,26 @@ const argv = yargs(hideBin(process.argv))
254
169
  description: 'Path to state file for resume (default: .fscopy-state.json)',
255
170
  default: '.fscopy-state.json',
256
171
  })
172
+ .option('verify', {
173
+ type: 'boolean',
174
+ description: 'Verify document counts after transfer',
175
+ default: false,
176
+ })
177
+ .option('rate-limit', {
178
+ type: 'number',
179
+ description: 'Limit transfer rate (documents per second, 0 = unlimited)',
180
+ default: 0,
181
+ })
182
+ .option('skip-oversized', {
183
+ type: 'boolean',
184
+ description: 'Skip documents exceeding 1MB instead of failing',
185
+ default: false,
186
+ })
187
+ .option('json', {
188
+ type: 'boolean',
189
+ description: 'Output results in JSON format (for CI/CD)',
190
+ default: false,
191
+ })
257
192
  .example('$0 --init config.ini', 'Generate INI config template (default)')
258
193
  .example('$0 --init config.json', 'Generate JSON config template')
259
194
  .example('$0 -f config.ini', 'Run transfer with config file')
@@ -274,508 +209,6 @@ const argv = yargs(hideBin(process.argv))
274
209
  .help()
275
210
  .parseSync() as CliArgs;
276
211
 
277
- // =============================================================================
278
- // Constants & Templates
279
- // =============================================================================
280
-
281
- const defaults: Config = {
282
- collections: [],
283
- includeSubcollections: false,
284
- dryRun: true,
285
- batchSize: 500,
286
- limit: 0,
287
- sourceProject: null,
288
- destProject: null,
289
- retries: 3,
290
- where: [],
291
- exclude: [],
292
- merge: false,
293
- parallel: 1,
294
- clear: false,
295
- deleteMissing: false,
296
- transform: null,
297
- renameCollection: {},
298
- idPrefix: null,
299
- idSuffix: null,
300
- webhook: null,
301
- resume: false,
302
- stateFile: '.fscopy-state.json',
303
- };
304
-
305
- const iniTemplate = `; fscopy configuration file
306
-
307
- [projects]
308
- source = my-source-project
309
- dest = my-dest-project
310
-
311
- [transfer]
312
- ; Comma-separated list of collections
313
- collections = collection1, collection2
314
- includeSubcollections = false
315
- dryRun = true
316
- batchSize = 500
317
- limit = 0
318
-
319
- [options]
320
- ; Filter documents: "field operator value" (operators: ==, !=, <, >, <=, >=)
321
- ; where = status == active
322
- ; Exclude subcollections by pattern (comma-separated, supports glob)
323
- ; exclude = logs, temp/*, cache
324
- ; Merge documents instead of overwriting
325
- merge = false
326
- ; Number of parallel collection transfers
327
- parallel = 1
328
- ; Clear destination collections before transfer (DESTRUCTIVE)
329
- clear = false
330
- ; Delete destination docs not present in source (sync mode)
331
- deleteMissing = false
332
- ; Transform documents during transfer (path to JS/TS file)
333
- ; transform = ./transforms/anonymize.ts
334
- ; Rename collections in destination (format: source:dest, comma-separated)
335
- ; renameCollection = users:users_backup, orders:orders_2024
336
- ; Add prefix or suffix to document IDs
337
- ; idPrefix = backup_
338
- ; idSuffix = _v2
339
- ; Webhook URL for transfer notifications (Slack, Discord, or custom)
340
- ; webhook = https://hooks.slack.com/services/...
341
- `;
342
-
343
- const jsonTemplate = {
344
- sourceProject: 'my-source-project',
345
- destProject: 'my-dest-project',
346
- collections: ['collection1', 'collection2'],
347
- includeSubcollections: false,
348
- dryRun: true,
349
- batchSize: 500,
350
- limit: 0,
351
- where: [],
352
- exclude: [],
353
- merge: false,
354
- parallel: 1,
355
- clear: false,
356
- deleteMissing: false,
357
- transform: null,
358
- renameCollection: {},
359
- idPrefix: null,
360
- idSuffix: null,
361
- webhook: null,
362
- };
363
-
364
- // =============================================================================
365
- // Logger
366
- // =============================================================================
367
-
368
- class Logger {
369
- private readonly logPath: string | undefined;
370
- private readonly entries: LogEntry[] = [];
371
- private readonly startTime: Date;
372
-
373
- constructor(logPath?: string) {
374
- this.logPath = logPath;
375
- this.startTime = new Date();
376
- }
377
-
378
- log(level: string, message: string, data: Record<string, unknown> = {}): void {
379
- const entry: LogEntry = {
380
- timestamp: new Date().toISOString(),
381
- level,
382
- message,
383
- ...data,
384
- };
385
- this.entries.push(entry);
386
-
387
- if (this.logPath) {
388
- const line =
389
- `[${entry.timestamp}] [${level}] ${message}` +
390
- (Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '') +
391
- '\n';
392
- fs.appendFileSync(this.logPath, line);
393
- }
394
- }
395
-
396
- info(message: string, data?: Record<string, unknown>): void {
397
- this.log('INFO', message, data);
398
- }
399
-
400
- error(message: string, data?: Record<string, unknown>): void {
401
- this.log('ERROR', message, data);
402
- }
403
-
404
- success(message: string, data?: Record<string, unknown>): void {
405
- this.log('SUCCESS', message, data);
406
- }
407
-
408
- init(): void {
409
- if (this.logPath) {
410
- const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
411
- fs.writeFileSync(this.logPath, header);
412
- }
413
- }
414
-
415
- summary(stats: Stats, duration: string): void {
416
- if (this.logPath) {
417
- let summary = `\n# Summary\n# Collections: ${stats.collectionsProcessed}\n`;
418
- if (stats.documentsDeleted > 0) {
419
- summary += `# Deleted: ${stats.documentsDeleted}\n`;
420
- }
421
- summary += `# Transferred: ${stats.documentsTransferred}\n# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
422
- fs.appendFileSync(this.logPath, summary);
423
- }
424
- }
425
- }
426
-
427
- // =============================================================================
428
- // Retry Logic
429
- // =============================================================================
430
-
431
- async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
432
- const { retries = 3, baseDelay = 1000, maxDelay = 30000, onRetry } = options;
433
-
434
- let lastError: Error | undefined;
435
- for (let attempt = 0; attempt <= retries; attempt++) {
436
- try {
437
- return await fn();
438
- } catch (error) {
439
- lastError = error as Error;
440
-
441
- if (attempt < retries) {
442
- const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
443
- if (onRetry) {
444
- onRetry(attempt + 1, retries, lastError, delay);
445
- }
446
- await new Promise((resolve) => setTimeout(resolve, delay));
447
- }
448
- }
449
- }
450
-
451
- throw lastError;
452
- }
453
-
454
- // =============================================================================
455
- // Config Parsing
456
- // =============================================================================
457
-
458
- function getFileFormat(filePath: string): 'json' | 'ini' {
459
- const ext = path.extname(filePath).toLowerCase();
460
- if (ext === '.json') return 'json';
461
- return 'ini';
462
- }
463
-
464
- function parseBoolean(val: unknown): boolean {
465
- if (typeof val === 'boolean') return val;
466
- if (typeof val === 'string') {
467
- return val.toLowerCase() === 'true';
468
- }
469
- return false;
470
- }
471
-
472
- function parseWhereFilter(filterStr: string): WhereFilter | null {
473
- // Parse "field operator value" format
474
- const operatorRegex = /(==|!=|<=|>=|<|>)/;
475
- const match = new RegExp(operatorRegex).exec(filterStr);
476
-
477
- if (!match) {
478
- console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing operator)`);
479
- return null;
480
- }
481
-
482
- const operator = match[0] as FirebaseFirestore.WhereFilterOp;
483
- const [fieldPart, valuePart] = filterStr.split(operatorRegex).filter((_, i) => i !== 1);
484
-
485
- if (!fieldPart || !valuePart) {
486
- console.warn(`āš ļø Invalid where filter: "${filterStr}" (missing field or value)`);
487
- return null;
488
- }
489
-
490
- const field = fieldPart.trim();
491
- const rawValue = valuePart.trim();
492
-
493
- // Parse value type
494
- let value: string | number | boolean;
495
- if (rawValue === 'true') {
496
- value = true;
497
- } else if (rawValue === 'false') {
498
- value = false;
499
- } else if (rawValue === 'null') {
500
- value = null as unknown as string; // Firestore supports null
501
- } else if (!Number.isNaN(Number(rawValue)) && rawValue !== '') {
502
- value = Number(rawValue);
503
- } else {
504
- // Remove quotes if present
505
- value = rawValue.replaceAll(/(?:^["'])|(?:["']$)/g, '');
506
- }
507
-
508
- return { field, operator, value };
509
- }
510
-
511
- function parseWhereFilters(filters: string[] | undefined): WhereFilter[] {
512
- if (!filters || filters.length === 0) return [];
513
- return filters.map(parseWhereFilter).filter((f): f is WhereFilter => f !== null);
514
- }
515
-
516
- function parseStringList(value: string | undefined): string[] {
517
- if (!value) return [];
518
- return value
519
- .split(',')
520
- .map((s) => s.trim())
521
- .filter((s) => s.length > 0);
522
- }
523
-
524
- function parseRenameMapping(mappings: string[] | string | undefined): Record<string, string> {
525
- if (!mappings) return {};
526
-
527
- const result: Record<string, string> = {};
528
- const items = Array.isArray(mappings) ? mappings : parseStringList(mappings);
529
-
530
- for (const item of items) {
531
- const mapping = String(item).trim();
532
- const colonIndex = mapping.indexOf(':');
533
- if (colonIndex === -1) {
534
- console.warn(`āš ļø Invalid rename mapping: "${mapping}" (missing ':')`);
535
- continue;
536
- }
537
- const source = mapping.slice(0, colonIndex).trim();
538
- const dest = mapping.slice(colonIndex + 1).trim();
539
- if (!source || !dest) {
540
- console.warn(`āš ļø Invalid rename mapping: "${mapping}" (empty source or dest)`);
541
- continue;
542
- }
543
- result[source] = dest;
544
- }
545
-
546
- return result;
547
- }
548
-
549
- function matchesExcludePattern(path: string, patterns: string[]): boolean {
550
- for (const pattern of patterns) {
551
- if (pattern.includes('*')) {
552
- // Convert glob pattern to regex
553
- const regex = new RegExp('^' + pattern.replaceAll('*', '.*') + '$');
554
- if (regex.test(path)) return true;
555
- } else if (path === pattern || path.endsWith('/' + pattern)) {
556
- // Exact match or ends with pattern
557
- return true;
558
- }
559
- }
560
- return false;
561
- }
562
-
563
- function parseIniConfig(content: string): Partial<Config> {
564
- const parsed = ini.parse(content) as {
565
- projects?: { source?: string; dest?: string };
566
- transfer?: {
567
- collections?: string;
568
- includeSubcollections?: string | boolean;
569
- dryRun?: string | boolean;
570
- batchSize?: string;
571
- limit?: string;
572
- };
573
- options?: {
574
- where?: string;
575
- exclude?: string;
576
- merge?: string | boolean;
577
- parallel?: string;
578
- clear?: string | boolean;
579
- deleteMissing?: string | boolean;
580
- transform?: string;
581
- renameCollection?: string;
582
- idPrefix?: string;
583
- idSuffix?: string;
584
- webhook?: string;
585
- };
586
- };
587
-
588
- let collections: string[] = [];
589
- if (parsed.transfer?.collections) {
590
- collections = parsed.transfer.collections
591
- .split(',')
592
- .map((c) => c.trim())
593
- .filter((c) => c.length > 0);
594
- }
595
-
596
- // Parse where filters from INI (single filter per line or comma-separated)
597
- const whereFilters = parsed.options?.where
598
- ? parseWhereFilters(parseStringList(parsed.options.where))
599
- : [];
600
-
601
- return {
602
- sourceProject: parsed.projects?.source ?? null,
603
- destProject: parsed.projects?.dest ?? null,
604
- collections,
605
- includeSubcollections: parseBoolean(parsed.transfer?.includeSubcollections),
606
- dryRun: parseBoolean(parsed.transfer?.dryRun ?? 'true'),
607
- batchSize: Number.parseInt(parsed.transfer?.batchSize ?? '', 10) || 500,
608
- limit: Number.parseInt(parsed.transfer?.limit ?? '', 10) || 0,
609
- where: whereFilters,
610
- exclude: parseStringList(parsed.options?.exclude),
611
- merge: parseBoolean(parsed.options?.merge),
612
- parallel: Number.parseInt(parsed.options?.parallel ?? '', 10) || 1,
613
- clear: parseBoolean(parsed.options?.clear),
614
- deleteMissing: parseBoolean(parsed.options?.deleteMissing),
615
- transform: parsed.options?.transform ?? null,
616
- renameCollection: parseRenameMapping(parsed.options?.renameCollection),
617
- idPrefix: parsed.options?.idPrefix ?? null,
618
- idSuffix: parsed.options?.idSuffix ?? null,
619
- webhook: parsed.options?.webhook ?? null,
620
- };
621
- }
622
-
623
- function parseJsonConfig(content: string): Partial<Config> {
624
- const config = JSON.parse(content) as {
625
- sourceProject?: string;
626
- destProject?: string;
627
- collections?: string[];
628
- includeSubcollections?: boolean;
629
- dryRun?: boolean;
630
- batchSize?: number;
631
- limit?: number;
632
- where?: string[];
633
- exclude?: string[];
634
- merge?: boolean;
635
- parallel?: number;
636
- clear?: boolean;
637
- deleteMissing?: boolean;
638
- transform?: string;
639
- renameCollection?: Record<string, string>;
640
- idPrefix?: string;
641
- idSuffix?: string;
642
- webhook?: string;
643
- };
644
-
645
- return {
646
- sourceProject: config.sourceProject ?? null,
647
- destProject: config.destProject ?? null,
648
- collections: config.collections,
649
- includeSubcollections: config.includeSubcollections,
650
- dryRun: config.dryRun,
651
- batchSize: config.batchSize,
652
- limit: config.limit,
653
- where: parseWhereFilters(config.where),
654
- exclude: config.exclude,
655
- merge: config.merge,
656
- parallel: config.parallel,
657
- clear: config.clear,
658
- deleteMissing: config.deleteMissing,
659
- transform: config.transform ?? null,
660
- renameCollection: config.renameCollection ?? {},
661
- idPrefix: config.idPrefix ?? null,
662
- idSuffix: config.idSuffix ?? null,
663
- webhook: config.webhook ?? null,
664
- };
665
- }
666
-
667
- function loadConfigFile(configPath?: string): Partial<Config> {
668
- if (!configPath) return {};
669
-
670
- const absolutePath = path.resolve(configPath);
671
- if (!fs.existsSync(absolutePath)) {
672
- throw new Error(`Config file not found: ${absolutePath}`);
673
- }
674
-
675
- const content = fs.readFileSync(absolutePath, 'utf-8');
676
- const format = getFileFormat(absolutePath);
677
-
678
- console.log(`šŸ“„ Loaded config from: ${absolutePath} (${format.toUpperCase()})\n`);
679
-
680
- return format === 'json' ? parseJsonConfig(content) : parseIniConfig(content);
681
- }
682
-
683
- function mergeConfig(defaultConfig: Config, fileConfig: Partial<Config>, cliArgs: CliArgs): Config {
684
- // Parse CLI where filters
685
- const cliWhereFilters = parseWhereFilters(cliArgs.where);
686
-
687
- // Parse CLI rename collection mappings
688
- const cliRenameCollection = parseRenameMapping(cliArgs.renameCollection);
689
-
690
- return {
691
- collections: cliArgs.collections ?? fileConfig.collections ?? defaultConfig.collections,
692
- includeSubcollections:
693
- cliArgs.includeSubcollections ??
694
- fileConfig.includeSubcollections ??
695
- defaultConfig.includeSubcollections,
696
- dryRun: cliArgs.dryRun ?? fileConfig.dryRun ?? defaultConfig.dryRun,
697
- batchSize: cliArgs.batchSize ?? fileConfig.batchSize ?? defaultConfig.batchSize,
698
- limit: cliArgs.limit ?? fileConfig.limit ?? defaultConfig.limit,
699
- sourceProject:
700
- cliArgs.sourceProject ?? fileConfig.sourceProject ?? defaultConfig.sourceProject,
701
- destProject: cliArgs.destProject ?? fileConfig.destProject ?? defaultConfig.destProject,
702
- retries: cliArgs.retries ?? defaultConfig.retries,
703
- where:
704
- cliWhereFilters.length > 0
705
- ? cliWhereFilters
706
- : (fileConfig.where ?? defaultConfig.where),
707
- exclude: cliArgs.exclude ?? fileConfig.exclude ?? defaultConfig.exclude,
708
- merge: cliArgs.merge ?? fileConfig.merge ?? defaultConfig.merge,
709
- parallel: cliArgs.parallel ?? fileConfig.parallel ?? defaultConfig.parallel,
710
- clear: cliArgs.clear ?? fileConfig.clear ?? defaultConfig.clear,
711
- deleteMissing: cliArgs.deleteMissing ?? fileConfig.deleteMissing ?? defaultConfig.deleteMissing,
712
- transform: cliArgs.transform ?? fileConfig.transform ?? defaultConfig.transform,
713
- renameCollection:
714
- Object.keys(cliRenameCollection).length > 0
715
- ? cliRenameCollection
716
- : (fileConfig.renameCollection ?? defaultConfig.renameCollection),
717
- idPrefix: cliArgs.idPrefix ?? fileConfig.idPrefix ?? defaultConfig.idPrefix,
718
- idSuffix: cliArgs.idSuffix ?? fileConfig.idSuffix ?? defaultConfig.idSuffix,
719
- webhook: cliArgs.webhook ?? fileConfig.webhook ?? defaultConfig.webhook,
720
- resume: cliArgs.resume ?? defaultConfig.resume,
721
- stateFile: cliArgs.stateFile ?? defaultConfig.stateFile,
722
- };
723
- }
724
-
725
- function validateConfig(config: Config): string[] {
726
- const errors: string[] = [];
727
-
728
- if (!config.sourceProject) {
729
- errors.push('Source project is required (--source-project or in config file)');
730
- }
731
- if (!config.destProject) {
732
- errors.push('Destination project is required (--dest-project or in config file)');
733
- }
734
- if (config.sourceProject && config.destProject && config.sourceProject === config.destProject) {
735
- // Same project is allowed only if we're renaming collections or modifying IDs
736
- const hasRenamedCollections = Object.keys(config.renameCollection).length > 0;
737
- const hasIdModification = config.idPrefix !== null || config.idSuffix !== null;
738
-
739
- if (!hasRenamedCollections && !hasIdModification) {
740
- errors.push(
741
- 'Source and destination projects are the same. ' +
742
- 'Use --rename-collection or --id-prefix/--id-suffix to avoid overwriting data.'
743
- );
744
- }
745
- }
746
- if (!config.collections || config.collections.length === 0) {
747
- errors.push('At least one collection is required (-c or --collections)');
748
- }
749
-
750
- return errors;
751
- }
752
-
753
- // =============================================================================
754
- // Config File Generation
755
- // =============================================================================
756
-
757
- function generateConfigFile(outputPath: string): boolean {
758
- const filePath = path.resolve(outputPath);
759
- const format = getFileFormat(filePath);
760
-
761
- if (fs.existsSync(filePath)) {
762
- console.error(`āŒ File already exists: ${filePath}`);
763
- console.error(' Use a different filename or delete the existing file.');
764
- process.exitCode = 1;
765
- return false;
766
- }
767
-
768
- const content = format === 'json' ? JSON.stringify(jsonTemplate, null, 4) : iniTemplate;
769
-
770
- fs.writeFileSync(filePath, content, 'utf-8');
771
-
772
- console.log(`āœ“ Config template created: ${filePath}`);
773
- console.log('');
774
- console.log('Edit the file to configure your transfer, then run:');
775
- console.log(` fscopy -f ${outputPath}`);
776
-
777
- return true;
778
- }
779
212
 
780
213
  // =============================================================================
781
214
  // Transform Loading
@@ -867,6 +300,12 @@ function displayConfig(config: Config): void {
867
300
  .join(', ');
868
301
  console.log(` šŸ·ļø ID modification: ${idMod}`);
869
302
  }
303
+ if (config.rateLimit > 0) {
304
+ console.log(` ā±ļø Rate limit: ${config.rateLimit} docs/s`);
305
+ }
306
+ if (config.skipOversized) {
307
+ console.log(` šŸ“ Skip oversized: enabled (skip docs > 1MB)`);
308
+ }
870
309
 
871
310
  console.log('');
872
311
 
@@ -895,116 +334,6 @@ async function askConfirmation(config: Config): Promise<boolean> {
895
334
  });
896
335
  }
897
336
 
898
- // =============================================================================
899
- // Interactive Mode
900
- // =============================================================================
901
-
902
- async function runInteractiveMode(config: Config): Promise<Config> {
903
- console.log('\n' + '='.repeat(60));
904
- console.log('šŸ”„ FSCOPY - INTERACTIVE MODE');
905
- console.log('='.repeat(60) + '\n');
906
-
907
- // Prompt for source project if not set
908
- let sourceProject = config.sourceProject;
909
- if (!sourceProject) {
910
- sourceProject = await input({
911
- message: 'Source Firebase project ID:',
912
- validate: (value) => value.length > 0 || 'Project ID is required',
913
- });
914
- } else {
915
- console.log(`šŸ“¤ Source project: ${sourceProject}`);
916
- }
917
-
918
- // Prompt for destination project if not set
919
- let destProject = config.destProject;
920
- if (!destProject) {
921
- destProject = await input({
922
- message: 'Destination Firebase project ID:',
923
- validate: (value) => {
924
- if (value.length === 0) return 'Project ID is required';
925
- if (value === sourceProject) return 'Must be different from source';
926
- return true;
927
- },
928
- });
929
- } else {
930
- console.log(`šŸ“„ Destination project: ${destProject}`);
931
- }
932
-
933
- // Initialize source Firebase to list collections
934
- console.log('\nšŸ“Š Connecting to source project...');
935
- const tempSourceApp = admin.initializeApp(
936
- {
937
- credential: admin.credential.applicationDefault(),
938
- projectId: sourceProject,
939
- },
940
- 'interactive-source'
941
- );
942
- const sourceDb = tempSourceApp.firestore();
943
-
944
- // List all root collections
945
- const rootCollections = await sourceDb.listCollections();
946
- const collectionIds = rootCollections.map((col) => col.id);
947
-
948
- if (collectionIds.length === 0) {
949
- console.log('\nāš ļø No collections found in source project');
950
- await tempSourceApp.delete();
951
- process.exit(0);
952
- }
953
-
954
- // Count documents in each collection for preview
955
- console.log('\nšŸ“‹ Available collections:');
956
- const collectionInfo: { id: string; count: number }[] = [];
957
- for (const id of collectionIds) {
958
- const snapshot = await sourceDb.collection(id).count().get();
959
- const count = snapshot.data().count;
960
- collectionInfo.push({ id, count });
961
- console.log(` - ${id} (${count} documents)`);
962
- }
963
-
964
- // Let user select collections
965
- console.log('');
966
- const selectedCollections = await checkbox({
967
- message: 'Select collections to transfer:',
968
- choices: collectionInfo.map((col) => ({
969
- name: `${col.id} (${col.count} docs)`,
970
- value: col.id,
971
- checked: config.collections.includes(col.id),
972
- })),
973
- validate: (value) => value.length > 0 || 'Select at least one collection',
974
- });
975
-
976
- // Ask about options
977
- console.log('');
978
- const includeSubcollections = await confirm({
979
- message: 'Include subcollections?',
980
- default: config.includeSubcollections,
981
- });
982
-
983
- const dryRun = await confirm({
984
- message: 'Dry run mode (preview without writing)?',
985
- default: config.dryRun,
986
- });
987
-
988
- const merge = await confirm({
989
- message: 'Merge mode (update instead of overwrite)?',
990
- default: config.merge,
991
- });
992
-
993
- // Clean up temporary app
994
- await tempSourceApp.delete();
995
-
996
- // Return updated config
997
- return {
998
- ...config,
999
- sourceProject,
1000
- destProject,
1001
- collections: selectedCollections,
1002
- includeSubcollections,
1003
- dryRun,
1004
- merge,
1005
- };
1006
- }
1007
-
1008
337
  // =============================================================================
1009
338
  // Firebase
1010
339
  // =============================================================================
@@ -1035,653 +364,46 @@ function initializeFirebase(config: Config): { sourceDb: Firestore; destDb: Fire
1035
364
  };
1036
365
  }
1037
366
 
1038
- async function cleanupFirebase(): Promise<void> {
1039
- if (sourceApp) await sourceApp.delete();
1040
- if (destApp) await destApp.delete();
1041
- }
1042
-
1043
- async function getSubcollections(docRef: DocumentReference): Promise<string[]> {
1044
- const collections = await docRef.listCollections();
1045
- return collections.map((col) => col.id);
1046
- }
1047
-
1048
- function getDestCollectionPath(
1049
- sourcePath: string,
1050
- renameMapping: Record<string, string>
1051
- ): string {
1052
- // Get the root collection name from the source path
1053
- const rootCollection = sourcePath.split('/')[0];
1054
-
1055
- // Check if this root collection should be renamed
1056
- if (renameMapping[rootCollection]) {
1057
- // Replace the root collection name with the destination name
1058
- return renameMapping[rootCollection] + sourcePath.slice(rootCollection.length);
1059
- }
1060
-
1061
- return sourcePath;
1062
- }
1063
-
1064
- function getDestDocId(
1065
- sourceId: string,
1066
- prefix: string | null,
1067
- suffix: string | null
1068
- ): string {
1069
- let destId = sourceId;
1070
- if (prefix) {
1071
- destId = prefix + destId;
1072
- }
1073
- if (suffix) {
1074
- destId = destId + suffix;
1075
- }
1076
- return destId;
1077
- }
1078
-
1079
- // =============================================================================
1080
- // Webhook
1081
- // =============================================================================
1082
-
1083
- interface WebhookPayload {
1084
- source: string;
1085
- destination: string;
1086
- collections: string[];
1087
- stats: Stats;
1088
- duration: number;
1089
- dryRun: boolean;
1090
- success: boolean;
1091
- error?: string;
1092
- }
1093
-
1094
- function detectWebhookType(url: string): 'slack' | 'discord' | 'custom' {
1095
- if (url.includes('hooks.slack.com')) {
1096
- return 'slack';
1097
- }
1098
- if (url.includes('discord.com/api/webhooks')) {
1099
- return 'discord';
1100
- }
1101
- return 'custom';
1102
- }
1103
-
1104
- function formatSlackPayload(payload: WebhookPayload): Record<string, unknown> {
1105
- const status = payload.success ? ':white_check_mark: Success' : ':x: Failed';
1106
- const mode = payload.dryRun ? ' (DRY RUN)' : '';
1107
-
1108
- const fields = [
1109
- { title: 'Source', value: payload.source, short: true },
1110
- { title: 'Destination', value: payload.destination, short: true },
1111
- { title: 'Collections', value: payload.collections.join(', '), short: false },
1112
- { title: 'Transferred', value: String(payload.stats.documentsTransferred), short: true },
1113
- { title: 'Deleted', value: String(payload.stats.documentsDeleted), short: true },
1114
- { title: 'Errors', value: String(payload.stats.errors), short: true },
1115
- { title: 'Duration', value: `${payload.duration}s`, short: true },
1116
- ];
1117
-
1118
- if (payload.error) {
1119
- fields.push({ title: 'Error', value: payload.error, short: false });
1120
- }
1121
-
1122
- return {
1123
- attachments: [
1124
- {
1125
- color: payload.success ? '#36a64f' : '#ff0000',
1126
- title: `fscopy Transfer${mode}`,
1127
- text: status,
1128
- fields,
1129
- footer: 'fscopy',
1130
- ts: Math.floor(Date.now() / 1000),
1131
- },
1132
- ],
1133
- };
1134
- }
1135
-
1136
- function formatDiscordPayload(payload: WebhookPayload): Record<string, unknown> {
1137
- const status = payload.success ? 'āœ… Success' : 'āŒ Failed';
1138
- const mode = payload.dryRun ? ' (DRY RUN)' : '';
1139
- const color = payload.success ? 0x36a64f : 0xff0000;
1140
-
1141
- const fields = [
1142
- { name: 'Source', value: payload.source, inline: true },
1143
- { name: 'Destination', value: payload.destination, inline: true },
1144
- { name: 'Collections', value: payload.collections.join(', '), inline: false },
1145
- { name: 'Transferred', value: String(payload.stats.documentsTransferred), inline: true },
1146
- { name: 'Deleted', value: String(payload.stats.documentsDeleted), inline: true },
1147
- { name: 'Errors', value: String(payload.stats.errors), inline: true },
1148
- { name: 'Duration', value: `${payload.duration}s`, inline: true },
1149
- ];
1150
-
1151
- if (payload.error) {
1152
- fields.push({ name: 'Error', value: payload.error, inline: false });
1153
- }
1154
-
1155
- return {
1156
- embeds: [
1157
- {
1158
- title: `fscopy Transfer${mode}`,
1159
- description: status,
1160
- color,
1161
- fields,
1162
- footer: { text: 'fscopy' },
1163
- timestamp: new Date().toISOString(),
1164
- },
1165
- ],
1166
- };
1167
- }
1168
-
1169
- async function sendWebhook(
1170
- webhookUrl: string,
1171
- payload: WebhookPayload,
1172
- logger: Logger
367
+ async function checkDatabaseConnectivity(
368
+ sourceDb: Firestore,
369
+ destDb: Firestore,
370
+ config: Config
1173
371
  ): Promise<void> {
1174
- const webhookType = detectWebhookType(webhookUrl);
1175
-
1176
- let body: Record<string, unknown>;
1177
- switch (webhookType) {
1178
- case 'slack':
1179
- body = formatSlackPayload(payload);
1180
- break;
1181
- case 'discord':
1182
- body = formatDiscordPayload(payload);
1183
- break;
1184
- default:
1185
- body = payload as unknown as Record<string, unknown>;
1186
- }
1187
-
1188
- try {
1189
- const response = await fetch(webhookUrl, {
1190
- method: 'POST',
1191
- headers: { 'Content-Type': 'application/json' },
1192
- body: JSON.stringify(body),
1193
- });
1194
-
1195
- if (!response.ok) {
1196
- const errorText = await response.text();
1197
- throw new Error(`HTTP ${response.status}: ${errorText}`);
1198
- }
1199
-
1200
- logger.info(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
1201
- console.log(`šŸ“¤ Webhook notification sent (${webhookType})`);
1202
- } catch (error) {
1203
- const message = error instanceof Error ? error.message : String(error);
1204
- logger.error(`Failed to send webhook: ${message}`, { url: webhookUrl });
1205
- console.error(`āš ļø Failed to send webhook: ${message}`);
1206
- }
1207
- }
1208
-
1209
- // =============================================================================
1210
- // State Management (Resume Support)
1211
- // =============================================================================
372
+ console.log('šŸ”Œ Checking database connectivity...');
1212
373
 
1213
- const STATE_VERSION = 1;
1214
-
1215
- function loadTransferState(stateFile: string): TransferState | null {
374
+ // Check source database
1216
375
  try {
1217
- if (!fs.existsSync(stateFile)) {
1218
- return null;
1219
- }
1220
- const content = fs.readFileSync(stateFile, 'utf-8');
1221
- const state = JSON.parse(content) as TransferState;
1222
-
1223
- if (state.version !== STATE_VERSION) {
1224
- console.warn(`āš ļø State file version mismatch (expected ${STATE_VERSION}, got ${state.version})`);
1225
- return null;
1226
- }
1227
-
1228
- return state;
376
+ await sourceDb.listCollections();
377
+ console.log(` āœ“ Source (${config.sourceProject}) - connected`);
1229
378
  } catch (error) {
1230
- console.error(`āš ļø Failed to load state file: ${(error as Error).message}`);
1231
- return null;
379
+ const err = error as Error & { code?: string };
380
+ const errorInfo = formatFirebaseError(err);
381
+ const hint = errorInfo.suggestion ? `\n Hint: ${errorInfo.suggestion}` : '';
382
+ throw new Error(`Cannot connect to source database (${config.sourceProject}): ${errorInfo.message}${hint}`);
1232
383
  }
1233
- }
1234
384
 
1235
- function saveTransferState(stateFile: string, state: TransferState): void {
1236
- state.updatedAt = new Date().toISOString();
1237
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
1238
- }
1239
-
1240
- function deleteTransferState(stateFile: string): void {
1241
- try {
1242
- if (fs.existsSync(stateFile)) {
1243
- fs.unlinkSync(stateFile);
1244
- }
1245
- } catch {
1246
- // Ignore errors when deleting state file
1247
- }
1248
- }
1249
-
1250
- function createInitialState(config: Config): TransferState {
1251
- return {
1252
- version: STATE_VERSION,
1253
- sourceProject: config.sourceProject!,
1254
- destProject: config.destProject!,
1255
- collections: config.collections,
1256
- startedAt: new Date().toISOString(),
1257
- updatedAt: new Date().toISOString(),
1258
- completedDocs: {},
1259
- stats: {
1260
- collectionsProcessed: 0,
1261
- documentsTransferred: 0,
1262
- documentsDeleted: 0,
1263
- errors: 0,
1264
- },
1265
- };
1266
- }
1267
-
1268
- function validateStateForResume(state: TransferState, config: Config): string[] {
1269
- const errors: string[] = [];
1270
-
1271
- if (state.sourceProject !== config.sourceProject) {
1272
- errors.push(`Source project mismatch: state has "${state.sourceProject}", config has "${config.sourceProject}"`);
1273
- }
1274
- if (state.destProject !== config.destProject) {
1275
- errors.push(`Destination project mismatch: state has "${state.destProject}", config has "${config.destProject}"`);
1276
- }
1277
-
1278
- // Check if collections are compatible (state collections should be subset of config)
1279
- const configCollections = new Set(config.collections);
1280
- for (const col of state.collections) {
1281
- if (!configCollections.has(col)) {
1282
- errors.push(`State contains collection "${col}" not in current config`);
1283
- }
1284
- }
1285
-
1286
- return errors;
1287
- }
1288
-
1289
- function isDocCompleted(state: TransferState, collectionPath: string, docId: string): boolean {
1290
- const completedInCollection = state.completedDocs[collectionPath];
1291
- return completedInCollection ? completedInCollection.includes(docId) : false;
1292
- }
1293
-
1294
- function markDocCompleted(state: TransferState, collectionPath: string, docId: string): void {
1295
- if (!state.completedDocs[collectionPath]) {
1296
- state.completedDocs[collectionPath] = [];
1297
- }
1298
- state.completedDocs[collectionPath].push(docId);
1299
- }
1300
-
1301
- // =============================================================================
1302
- // Transfer Logic
1303
- // =============================================================================
1304
-
1305
- async function clearCollection(
1306
- db: Firestore,
1307
- collectionPath: string,
1308
- config: Config,
1309
- logger: Logger,
1310
- includeSubcollections: boolean
1311
- ): Promise<number> {
1312
- let deletedCount = 0;
1313
- const collectionRef = db.collection(collectionPath);
1314
- const snapshot = await collectionRef.get();
1315
-
1316
- if (snapshot.empty) {
1317
- return 0;
1318
- }
1319
-
1320
- // Delete subcollections first if enabled
1321
- if (includeSubcollections) {
1322
- for (const doc of snapshot.docs) {
1323
- const subcollections = await getSubcollections(doc.ref);
1324
- for (const subId of subcollections) {
1325
- // Check exclude patterns
1326
- if (matchesExcludePattern(subId, config.exclude)) {
1327
- continue;
1328
- }
1329
- const subPath = `${collectionPath}/${doc.id}/${subId}`;
1330
- deletedCount += await clearCollection(db, subPath, config, logger, true);
1331
- }
1332
- }
1333
- }
1334
-
1335
- // Delete documents in batches
1336
- const docs = snapshot.docs;
1337
- for (let i = 0; i < docs.length; i += config.batchSize) {
1338
- const batch = docs.slice(i, i + config.batchSize);
1339
- const writeBatch = db.batch();
1340
-
1341
- for (const doc of batch) {
1342
- writeBatch.delete(doc.ref);
1343
- deletedCount++;
1344
- }
1345
-
1346
- if (!config.dryRun) {
1347
- await withRetry(() => writeBatch.commit(), {
1348
- retries: config.retries,
1349
- onRetry: (attempt, max, err, delay) => {
1350
- logger.error(`Retry delete ${attempt}/${max} for ${collectionPath}`, {
1351
- error: err.message,
1352
- delay,
1353
- });
1354
- },
1355
- });
1356
- }
1357
-
1358
- logger.info(`Deleted ${batch.length} documents from ${collectionPath}`);
1359
- }
1360
-
1361
- return deletedCount;
1362
- }
1363
-
1364
- async function deleteOrphanDocuments(
1365
- sourceDb: Firestore,
1366
- destDb: Firestore,
1367
- sourceCollectionPath: string,
1368
- config: Config,
1369
- logger: Logger
1370
- ): Promise<number> {
1371
- let deletedCount = 0;
1372
-
1373
- // Get the destination path (may be renamed)
1374
- const destCollectionPath = getDestCollectionPath(sourceCollectionPath, config.renameCollection);
1375
-
1376
- // Get all document IDs from source
1377
- const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).get();
1378
- const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
1379
-
1380
- // Get all document IDs from destination
1381
- const destSnapshot = await destDb.collection(destCollectionPath).get();
1382
-
1383
- // Find orphan documents (in dest but not in source)
1384
- const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
1385
-
1386
- if (orphanDocs.length === 0) {
1387
- return 0;
1388
- }
1389
-
1390
- logger.info(`Found ${orphanDocs.length} orphan documents in ${destCollectionPath}`);
1391
-
1392
- // Delete orphan documents in batches
1393
- for (let i = 0; i < orphanDocs.length; i += config.batchSize) {
1394
- const batch = orphanDocs.slice(i, i + config.batchSize);
1395
- const writeBatch = destDb.batch();
1396
-
1397
- for (const doc of batch) {
1398
- // If subcollections are included, recursively delete orphans in subcollections first
1399
- if (config.includeSubcollections) {
1400
- const subcollections = await getSubcollections(doc.ref);
1401
- for (const subId of subcollections) {
1402
- if (matchesExcludePattern(subId, config.exclude)) {
1403
- continue;
1404
- }
1405
- const subPath = `${destCollectionPath}/${doc.id}/${subId}`;
1406
- // For orphan parent docs, clear all subcollection data
1407
- deletedCount += await clearCollection(destDb, subPath, config, logger, true);
1408
- }
1409
- }
1410
-
1411
- writeBatch.delete(doc.ref);
1412
- deletedCount++;
1413
- }
1414
-
1415
- if (!config.dryRun) {
1416
- await withRetry(() => writeBatch.commit(), {
1417
- retries: config.retries,
1418
- onRetry: (attempt, max, err, delay) => {
1419
- logger.error(`Retry delete orphans ${attempt}/${max} for ${destCollectionPath}`, {
1420
- error: err.message,
1421
- delay,
1422
- });
1423
- },
1424
- });
1425
- }
1426
-
1427
- logger.info(`Deleted ${batch.length} orphan documents from ${destCollectionPath}`);
1428
- }
1429
-
1430
- // Also check subcollections of existing documents for orphans
1431
- if (config.includeSubcollections) {
1432
- for (const sourceDoc of sourceSnapshot.docs) {
1433
- const sourceSubcollections = await getSubcollections(sourceDoc.ref);
1434
- for (const subId of sourceSubcollections) {
1435
- if (matchesExcludePattern(subId, config.exclude)) {
1436
- continue;
1437
- }
1438
- const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
1439
- deletedCount += await deleteOrphanDocuments(sourceDb, destDb, subPath, config, logger);
1440
- }
1441
- }
1442
- }
1443
-
1444
- return deletedCount;
1445
- }
1446
-
1447
- async function countDocuments(
1448
- sourceDb: Firestore,
1449
- collectionPath: string,
1450
- config: Config,
1451
- depth: number = 0
1452
- ): Promise<number> {
1453
- let count = 0;
1454
-
1455
- // Build query with where filters (only at root level)
1456
- let query: FirebaseFirestore.Query = sourceDb.collection(collectionPath);
1457
- if (depth === 0 && config.where.length > 0) {
1458
- for (const filter of config.where) {
1459
- query = query.where(filter.field, filter.operator, filter.value);
1460
- }
1461
- }
1462
-
1463
- const snapshot = await query.get();
1464
- count += snapshot.size;
1465
-
1466
- if (config.includeSubcollections) {
1467
- for (const doc of snapshot.docs) {
1468
- const subcollections = await getSubcollections(doc.ref);
1469
- for (const subId of subcollections) {
1470
- const subPath = `${collectionPath}/${doc.id}/${subId}`;
1471
-
1472
- // Check exclude patterns
1473
- if (matchesExcludePattern(subId, config.exclude)) {
1474
- continue;
1475
- }
1476
-
1477
- count += await countDocuments(sourceDb, subPath, config, depth + 1);
1478
- }
1479
- }
1480
- }
1481
-
1482
- return count;
1483
- }
1484
-
1485
- interface TransferContext {
1486
- sourceDb: Firestore;
1487
- destDb: Firestore;
1488
- config: Config;
1489
- stats: Stats;
1490
- logger: Logger;
1491
- progressBar: cliProgress.SingleBar | null;
1492
- transformFn: TransformFunction | null;
1493
- state: TransferState | null;
1494
- }
1495
-
1496
- async function transferCollection(
1497
- ctx: TransferContext,
1498
- collectionPath: string,
1499
- depth: number = 0
1500
- ): Promise<void> {
1501
- const { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state } = ctx;
1502
-
1503
- // Get the destination path (may be renamed)
1504
- const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
1505
-
1506
- const sourceCollectionRef = sourceDb.collection(collectionPath);
1507
- let query: FirebaseFirestore.Query = sourceCollectionRef;
1508
-
1509
- // Apply where filters (only at root level)
1510
- if (depth === 0 && config.where.length > 0) {
1511
- for (const filter of config.where) {
1512
- query = query.where(filter.field, filter.operator, filter.value);
385
+ // Check destination database (only if different from source)
386
+ if (config.sourceProject !== config.destProject) {
387
+ try {
388
+ await destDb.listCollections();
389
+ console.log(` āœ“ Destination (${config.destProject}) - connected`);
390
+ } catch (error) {
391
+ const err = error as Error & { code?: string };
392
+ const errorInfo = formatFirebaseError(err);
393
+ const hint = errorInfo.suggestion ? `\n Hint: ${errorInfo.suggestion}` : '';
394
+ throw new Error(`Cannot connect to destination database (${config.destProject}): ${errorInfo.message}${hint}`);
1513
395
  }
396
+ } else {
397
+ console.log(` āœ“ Destination (same as source) - connected`);
1514
398
  }
1515
399
 
1516
- if (config.limit > 0 && depth === 0) {
1517
- query = query.limit(config.limit);
1518
- }
1519
-
1520
- const snapshot = await withRetry(() => query.get(), {
1521
- retries: config.retries,
1522
- onRetry: (attempt, max, err, delay) => {
1523
- logger.error(`Retry ${attempt}/${max} for ${collectionPath}`, {
1524
- error: err.message,
1525
- delay,
1526
- });
1527
- },
1528
- });
1529
-
1530
- if (snapshot.empty) {
1531
- return;
1532
- }
1533
-
1534
- stats.collectionsProcessed++;
1535
- logger.info(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
1536
-
1537
- const docs = snapshot.docs;
1538
- const batchDocIds: string[] = []; // Track docs in current batch for state saving
1539
-
1540
- for (let i = 0; i < docs.length; i += config.batchSize) {
1541
- const batch = docs.slice(i, i + config.batchSize);
1542
- const destBatch: WriteBatch = destDb.batch();
1543
- batchDocIds.length = 0; // Clear for new batch
1544
-
1545
- for (const doc of batch) {
1546
- // Skip if already completed (resume mode)
1547
- if (state && isDocCompleted(state, collectionPath, doc.id)) {
1548
- if (progressBar) {
1549
- progressBar.increment();
1550
- }
1551
- stats.documentsTransferred++;
1552
- continue;
1553
- }
1554
-
1555
- // Get destination document ID (with optional prefix/suffix)
1556
- const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
1557
- const destDocRef = destDb.collection(destCollectionPath).doc(destDocId);
1558
-
1559
- // Apply transform if provided
1560
- let docData = doc.data() as Record<string, unknown>;
1561
- if (transformFn) {
1562
- const transformed = transformFn(docData, {
1563
- id: doc.id,
1564
- path: `${collectionPath}/${doc.id}`,
1565
- });
1566
- if (transformed === null) {
1567
- // Skip this document if transform returns null
1568
- logger.info('Skipped document (transform returned null)', {
1569
- collection: collectionPath,
1570
- docId: doc.id,
1571
- });
1572
- if (progressBar) {
1573
- progressBar.increment();
1574
- }
1575
- // Mark as completed even if skipped
1576
- batchDocIds.push(doc.id);
1577
- continue;
1578
- }
1579
- docData = transformed;
1580
- }
1581
-
1582
- if (!config.dryRun) {
1583
- // Use merge option if enabled
1584
- if (config.merge) {
1585
- destBatch.set(destDocRef, docData, { merge: true });
1586
- } else {
1587
- destBatch.set(destDocRef, docData);
1588
- }
1589
- }
1590
-
1591
- batchDocIds.push(doc.id);
1592
- stats.documentsTransferred++;
1593
- if (progressBar) {
1594
- progressBar.increment();
1595
- }
1596
-
1597
- logger.info('Transferred document', {
1598
- source: collectionPath,
1599
- dest: destCollectionPath,
1600
- sourceDocId: doc.id,
1601
- destDocId: destDocId,
1602
- });
1603
-
1604
- if (config.includeSubcollections) {
1605
- const subcollections = await getSubcollections(doc.ref);
1606
-
1607
- for (const subcollectionId of subcollections) {
1608
- // Check exclude patterns
1609
- if (matchesExcludePattern(subcollectionId, config.exclude)) {
1610
- logger.info(`Skipping excluded subcollection: ${subcollectionId}`);
1611
- continue;
1612
- }
1613
-
1614
- const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
1615
-
1616
- await transferCollection(
1617
- { ...ctx, config: { ...config, limit: 0, where: [] } },
1618
- subcollectionPath,
1619
- depth + 1
1620
- );
1621
- }
1622
- }
1623
- }
1624
-
1625
- if (!config.dryRun && batch.length > 0) {
1626
- await withRetry(() => destBatch.commit(), {
1627
- retries: config.retries,
1628
- onRetry: (attempt, max, err, delay) => {
1629
- logger.error(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
1630
- },
1631
- });
1632
-
1633
- // Save state after successful batch commit (for resume support)
1634
- if (state && batchDocIds.length > 0) {
1635
- for (const docId of batchDocIds) {
1636
- markDocCompleted(state, collectionPath, docId);
1637
- }
1638
- state.stats = { ...stats };
1639
- saveTransferState(config.stateFile, state);
1640
- }
1641
- }
1642
- }
400
+ console.log('');
1643
401
  }
1644
402
 
1645
- // =============================================================================
1646
- // Parallel Processing Helper
1647
- // =============================================================================
1648
-
1649
- async function processInParallel<T, R>(
1650
- items: T[],
1651
- concurrency: number,
1652
- processor: (item: T) => Promise<R>
1653
- ): Promise<R[]> {
1654
- const results: R[] = [];
1655
- const executing: Promise<void>[] = [];
1656
-
1657
- for (const item of items) {
1658
- const promise = processor(item).then((result) => {
1659
- results.push(result);
1660
- });
1661
-
1662
- executing.push(promise);
1663
-
1664
- if (executing.length >= concurrency) {
1665
- await Promise.race(executing);
1666
- // Remove completed promises
1667
- for (let i = executing.length - 1; i >= 0; i--) {
1668
- const p = executing[i];
1669
- // Check if promise is settled by racing with resolved promise
1670
- const isSettled = await Promise.race([
1671
- p.then(() => true).catch(() => true),
1672
- Promise.resolve(false),
1673
- ]);
1674
- if (isSettled) {
1675
- executing.splice(i, 1);
1676
- }
1677
- }
1678
- }
1679
- }
1680
-
1681
- await Promise.all(executing);
1682
- return results;
403
+ async function cleanupFirebase(): Promise<void> {
404
+ if (sourceApp) await sourceApp.delete();
405
+ if (destApp) await destApp.delete();
1683
406
  }
1684
-
1685
407
  // =============================================================================
1686
408
  // Main
1687
409
  // =============================================================================
@@ -1693,6 +415,9 @@ if (argv.init !== undefined) {
1693
415
  process.exit(0);
1694
416
  }
1695
417
 
418
+ // Check credentials before proceeding
419
+ ensureCredentials();
420
+
1696
421
  // Main transfer flow
1697
422
  let config: Config = defaults;
1698
423
  let logger: Logger | null = null;
@@ -1773,6 +498,45 @@ try {
1773
498
 
1774
499
  const { sourceDb, destDb } = initializeFirebase(config);
1775
500
 
501
+ // Verify database connectivity before proceeding
502
+ await checkDatabaseConnectivity(sourceDb, destDb, config);
503
+
504
+ // Validate transform with sample documents (in dry-run mode)
505
+ if (transformFn && config.dryRun) {
506
+ console.log('🧪 Validating transform with sample documents...');
507
+ let samplesTested = 0;
508
+ let samplesSkipped = 0;
509
+ let samplesErrors = 0;
510
+
511
+ for (const collection of config.collections) {
512
+ const snapshot = await sourceDb.collection(collection).limit(3).get();
513
+ for (const doc of snapshot.docs) {
514
+ try {
515
+ const result = transformFn(doc.data() as Record<string, unknown>, {
516
+ id: doc.id,
517
+ path: `${collection}/${doc.id}`,
518
+ });
519
+ if (result === null) {
520
+ samplesSkipped++;
521
+ } else {
522
+ samplesTested++;
523
+ }
524
+ } catch (error) {
525
+ samplesErrors++;
526
+ const err = error as Error;
527
+ console.error(` āš ļø Transform error on ${collection}/${doc.id}: ${err.message}`);
528
+ }
529
+ }
530
+ }
531
+
532
+ if (samplesErrors > 0) {
533
+ console.log(` āŒ ${samplesErrors} sample(s) failed - review your transform function`);
534
+ } else if (samplesTested > 0 || samplesSkipped > 0) {
535
+ console.log(` āœ“ Tested ${samplesTested} sample(s), ${samplesSkipped} would be skipped`);
536
+ }
537
+ console.log('');
538
+ }
539
+
1776
540
  if (!config.resume) {
1777
541
  stats = {
1778
542
  collectionsProcessed: 0,
@@ -1788,19 +552,66 @@ try {
1788
552
 
1789
553
  if (!argv.quiet) {
1790
554
  console.log('šŸ“Š Counting documents...');
555
+ let lastSubcollectionLog = Date.now();
556
+ let subcollectionCount = 0;
557
+
558
+ const countProgress: CountProgress = {
559
+ onCollection: (path, count) => {
560
+ console.log(` ${path}: ${count} documents`);
561
+ },
562
+ onSubcollection: (_path) => {
563
+ subcollectionCount++;
564
+ // Show progress every 2 seconds to avoid flooding the console
565
+ const now = Date.now();
566
+ if (now - lastSubcollectionLog > 2000) {
567
+ process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
568
+ lastSubcollectionLog = now;
569
+ }
570
+ },
571
+ };
572
+
1791
573
  for (const collection of config.collections) {
1792
- totalDocs += await countDocuments(sourceDb, collection, config);
574
+ totalDocs += await countDocuments(sourceDb, collection, config, 0, countProgress);
575
+ }
576
+
577
+ // Clear the subcollection line if any were found
578
+ if (subcollectionCount > 0) {
579
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
580
+ console.log(` Subcollections scanned: ${subcollectionCount}`);
1793
581
  }
1794
- console.log(` Found ${totalDocs} documents to transfer\n`);
582
+ console.log(` Total: ${totalDocs} documents to transfer\n`);
1795
583
 
1796
584
  if (totalDocs > 0) {
1797
585
  progressBar = new cliProgress.SingleBar({
1798
- format: 'šŸ“¦ Progress |{bar}| {percentage}% | {value}/{total} docs | ETA: {eta}s',
586
+ format: 'šŸ“¦ Progress |{bar}| {percentage}% | {value}/{total} docs | {speed} docs/s | ETA: {eta}s',
1799
587
  barCompleteChar: 'ā–ˆ',
1800
588
  barIncompleteChar: 'ā–‘',
1801
589
  hideCursor: true,
1802
590
  });
1803
- progressBar.start(totalDocs, 0);
591
+ progressBar.start(totalDocs, 0, { speed: '0' });
592
+
593
+ // Track speed using transfer stats
594
+ let lastDocsTransferred = 0;
595
+ let lastTime = Date.now();
596
+
597
+ const speedInterval = setInterval(() => {
598
+ if (progressBar) {
599
+ const now = Date.now();
600
+ const timeDiff = (now - lastTime) / 1000;
601
+ const currentDocs = stats.documentsTransferred;
602
+
603
+ if (timeDiff > 0) {
604
+ const docsDiff = currentDocs - lastDocsTransferred;
605
+ const speed = Math.round(docsDiff / timeDiff);
606
+ lastDocsTransferred = currentDocs;
607
+ lastTime = now;
608
+ progressBar.update({ speed: String(speed) });
609
+ }
610
+ }
611
+ }, 500);
612
+
613
+ // Store interval for cleanup
614
+ (progressBar as unknown as { _speedInterval: NodeJS.Timeout })._speedInterval = speedInterval;
1804
615
  }
1805
616
  }
1806
617
 
@@ -1821,20 +632,43 @@ try {
1821
632
  console.log(` Deleted ${stats.documentsDeleted} documents\n`);
1822
633
  }
1823
634
 
1824
- const ctx: TransferContext = { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state: transferState };
635
+ // Create rate limiter if enabled
636
+ const rateLimiter = config.rateLimit > 0 ? new RateLimiter(config.rateLimit) : null;
637
+ if (rateLimiter) {
638
+ console.log(`ā±ļø Rate limiting enabled: ${config.rateLimit} docs/s\n`);
639
+ }
640
+
641
+ const ctx: TransferContext = { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state: transferState, rateLimiter };
1825
642
 
1826
643
  // Transfer collections (with optional parallelism)
1827
644
  if (config.parallel > 1) {
1828
- await processInParallel(config.collections, config.parallel, (collection) =>
645
+ const { errors } = await processInParallel(config.collections, config.parallel, (collection) =>
1829
646
  transferCollection(ctx, collection)
1830
647
  );
648
+ if (errors.length > 0) {
649
+ for (const err of errors) {
650
+ logger.error('Parallel transfer error', { error: err.message });
651
+ stats.errors++;
652
+ }
653
+ }
1831
654
  } else {
1832
655
  for (const collection of config.collections) {
1833
- await transferCollection(ctx, collection);
656
+ try {
657
+ await transferCollection(ctx, collection);
658
+ } catch (error) {
659
+ const err = error instanceof Error ? error : new Error(String(error));
660
+ logger.error(`Transfer failed for ${collection}`, { error: err.message });
661
+ stats.errors++;
662
+ }
1834
663
  }
1835
664
  }
1836
665
 
1837
666
  if (progressBar) {
667
+ // Clear speed update interval
668
+ const interval = (progressBar as unknown as { _speedInterval?: NodeJS.Timeout })._speedInterval;
669
+ if (interval) {
670
+ clearInterval(interval);
671
+ }
1838
672
  progressBar.stop();
1839
673
  }
1840
674
 
@@ -1866,30 +700,95 @@ try {
1866
700
  });
1867
701
  logger.summary(stats, duration);
1868
702
 
1869
- console.log('\n' + '='.repeat(60));
1870
- console.log('šŸ“Š TRANSFER SUMMARY');
1871
- console.log('='.repeat(60));
1872
- console.log(`Collections processed: ${stats.collectionsProcessed}`);
1873
- if (stats.documentsDeleted > 0) {
1874
- console.log(`Documents deleted: ${stats.documentsDeleted}`);
703
+ // Verify transfer if enabled (and not dry-run)
704
+ let verifyResult: Record<string, { source: number; dest: number; match: boolean }> | null = null;
705
+ if (config.verify && !config.dryRun) {
706
+ if (!config.json) {
707
+ console.log('\nšŸ” Verifying transfer...');
708
+ }
709
+ verifyResult = {};
710
+ let verifyPassed = true;
711
+
712
+ for (const collection of config.collections) {
713
+ const destCollection = getDestCollectionPath(collection, config.renameCollection);
714
+
715
+ // Count source documents
716
+ const sourceCount = await sourceDb.collection(collection).count().get();
717
+ const sourceTotal = sourceCount.data().count;
718
+
719
+ // Count destination documents
720
+ const destCount = await destDb.collection(destCollection).count().get();
721
+ const destTotal = destCount.data().count;
722
+
723
+ const match = sourceTotal === destTotal;
724
+ verifyResult[collection] = { source: sourceTotal, dest: destTotal, match };
725
+
726
+ if (!config.json) {
727
+ if (match) {
728
+ console.log(` āœ“ ${collection}: ${sourceTotal} docs (matched)`);
729
+ } else {
730
+ console.log(` āš ļø ${collection}: source=${sourceTotal}, dest=${destTotal} (mismatch)`);
731
+ }
732
+ }
733
+ if (!match) verifyPassed = false;
734
+ }
735
+
736
+ if (!config.json) {
737
+ if (verifyPassed) {
738
+ console.log(' āœ“ Verification passed');
739
+ } else {
740
+ console.log(' āš ļø Verification found mismatches');
741
+ }
742
+ }
1875
743
  }
1876
- console.log(`Documents transferred: ${stats.documentsTransferred}`);
1877
- console.log(`Errors: ${stats.errors}`);
1878
- console.log(`Duration: ${duration}s`);
1879
744
 
1880
- if (argv.log) {
1881
- console.log(`Log file: ${argv.log}`);
745
+ // Delete state file on successful completion (before JSON output)
746
+ if (!config.dryRun) {
747
+ deleteTransferState(config.stateFile);
1882
748
  }
1883
749
 
1884
- if (config.dryRun) {
1885
- console.log('\n⚠ DRY RUN: No data was actually written');
1886
- console.log(' Run with --dry-run=false to perform the transfer');
750
+ // JSON output mode
751
+ if (config.json) {
752
+ const jsonOutput = {
753
+ success: true,
754
+ dryRun: config.dryRun,
755
+ source: config.sourceProject,
756
+ destination: config.destProject,
757
+ collections: config.collections,
758
+ stats: {
759
+ collectionsProcessed: stats.collectionsProcessed,
760
+ documentsTransferred: stats.documentsTransferred,
761
+ documentsDeleted: stats.documentsDeleted,
762
+ errors: stats.errors,
763
+ },
764
+ duration: Number.parseFloat(duration),
765
+ verify: verifyResult,
766
+ };
767
+ console.log(JSON.stringify(jsonOutput, null, 2));
1887
768
  } else {
1888
- console.log('\nāœ“ Transfer completed successfully');
1889
- // Delete state file on successful completion
1890
- deleteTransferState(config.stateFile);
769
+ console.log('\n' + '='.repeat(60));
770
+ console.log('šŸ“Š TRANSFER SUMMARY');
771
+ console.log('='.repeat(60));
772
+ console.log(`Collections processed: ${stats.collectionsProcessed}`);
773
+ if (stats.documentsDeleted > 0) {
774
+ console.log(`Documents deleted: ${stats.documentsDeleted}`);
775
+ }
776
+ console.log(`Documents transferred: ${stats.documentsTransferred}`);
777
+ console.log(`Errors: ${stats.errors}`);
778
+ console.log(`Duration: ${duration}s`);
779
+
780
+ if (argv.log) {
781
+ console.log(`Log file: ${argv.log}`);
782
+ }
783
+
784
+ if (config.dryRun) {
785
+ console.log('\n⚠ DRY RUN: No data was actually written');
786
+ console.log(' Run with --dry-run=false to perform the transfer');
787
+ } else {
788
+ console.log('\nāœ“ Transfer completed successfully');
789
+ }
790
+ console.log('='.repeat(60) + '\n');
1891
791
  }
1892
- console.log('='.repeat(60) + '\n');
1893
792
 
1894
793
  // Send webhook notification if configured
1895
794
  if (config.webhook) {
@@ -1911,7 +810,29 @@ try {
1911
810
  await cleanupFirebase();
1912
811
  } catch (error) {
1913
812
  const errorMessage = (error as Error).message;
1914
- console.error('\nāŒ Error during transfer:', errorMessage);
813
+ const duration = Number.parseFloat(((Date.now() - startTime) / 1000).toFixed(2));
814
+
815
+ // JSON output mode for errors
816
+ if (config.json) {
817
+ const jsonOutput = {
818
+ success: false,
819
+ error: errorMessage,
820
+ dryRun: config.dryRun,
821
+ source: config.sourceProject,
822
+ destination: config.destProject,
823
+ collections: config.collections,
824
+ stats: {
825
+ collectionsProcessed: stats.collectionsProcessed,
826
+ documentsTransferred: stats.documentsTransferred,
827
+ documentsDeleted: stats.documentsDeleted,
828
+ errors: stats.errors,
829
+ },
830
+ duration,
831
+ };
832
+ console.log(JSON.stringify(jsonOutput, null, 2));
833
+ } else {
834
+ console.error('\nāŒ Error during transfer:', errorMessage);
835
+ }
1915
836
 
1916
837
  // Send webhook notification on error if configured
1917
838
  if (config.webhook && logger) {
@@ -1922,7 +843,7 @@ try {
1922
843
  destination: config.destProject ?? 'unknown',
1923
844
  collections: config.collections,
1924
845
  stats,
1925
- duration: Number.parseFloat(((Date.now() - startTime) / 1000).toFixed(2)),
846
+ duration,
1926
847
  dryRun: config.dryRun,
1927
848
  success: false,
1928
849
  error: errorMessage,