@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.
- package/README.md +21 -1
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1214
|
-
|
|
1215
|
-
function loadTransferState(stateFile: string): TransferState | null {
|
|
374
|
+
// Check source database
|
|
1216
375
|
try {
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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
|
-
|
|
1881
|
-
|
|
745
|
+
// Delete state file on successful completion (before JSON output)
|
|
746
|
+
if (!config.dryRun) {
|
|
747
|
+
deleteTransferState(config.stateFile);
|
|
1882
748
|
}
|
|
1883
749
|
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
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
|
|
1889
|
-
|
|
1890
|
-
|
|
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
|
-
|
|
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
|
|
846
|
+
duration,
|
|
1926
847
|
dryRun: config.dryRun,
|
|
1927
848
|
success: false,
|
|
1928
849
|
error: errorMessage,
|