@fazetitans/fscopy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +409 -0
- package/package.json +74 -0
- package/src/cli.ts +1936 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,1936 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import admin from 'firebase-admin';
|
|
4
|
+
import type { Firestore, DocumentReference, WriteBatch } from 'firebase-admin/firestore';
|
|
5
|
+
import yargs from 'yargs';
|
|
6
|
+
import { hideBin } from 'yargs/helpers';
|
|
7
|
+
import ini from 'ini';
|
|
8
|
+
import cliProgress from 'cli-progress';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
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
|
+
|
|
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
|
+
}
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// CLI Arguments
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
const argv = yargs(hideBin(process.argv))
|
|
120
|
+
.scriptName('fscopy')
|
|
121
|
+
.usage('$0 [options]')
|
|
122
|
+
.option('init', {
|
|
123
|
+
type: 'string',
|
|
124
|
+
description: 'Generate a config template file (.ini by default, .json if specified)',
|
|
125
|
+
nargs: 1,
|
|
126
|
+
default: undefined,
|
|
127
|
+
})
|
|
128
|
+
.option('config', {
|
|
129
|
+
alias: 'f',
|
|
130
|
+
type: 'string',
|
|
131
|
+
description: 'Path to config file (.ini or .json)',
|
|
132
|
+
})
|
|
133
|
+
.option('collections', {
|
|
134
|
+
alias: 'c',
|
|
135
|
+
type: 'array',
|
|
136
|
+
description: 'Collections to transfer (e.g., -c users orders)',
|
|
137
|
+
})
|
|
138
|
+
.option('include-subcollections', {
|
|
139
|
+
alias: 's',
|
|
140
|
+
type: 'boolean',
|
|
141
|
+
description: 'Include subcollections in transfer',
|
|
142
|
+
})
|
|
143
|
+
.option('dry-run', {
|
|
144
|
+
alias: 'd',
|
|
145
|
+
type: 'boolean',
|
|
146
|
+
description: 'Preview transfer without writing',
|
|
147
|
+
})
|
|
148
|
+
.option('batch-size', {
|
|
149
|
+
alias: 'b',
|
|
150
|
+
type: 'number',
|
|
151
|
+
description: 'Number of documents per batch write',
|
|
152
|
+
})
|
|
153
|
+
.option('limit', {
|
|
154
|
+
alias: 'l',
|
|
155
|
+
type: 'number',
|
|
156
|
+
description: 'Limit number of documents per collection (0 = no limit)',
|
|
157
|
+
})
|
|
158
|
+
.option('source-project', {
|
|
159
|
+
type: 'string',
|
|
160
|
+
description: 'Source Firebase project ID',
|
|
161
|
+
})
|
|
162
|
+
.option('dest-project', {
|
|
163
|
+
type: 'string',
|
|
164
|
+
description: 'Destination Firebase project ID',
|
|
165
|
+
})
|
|
166
|
+
.option('yes', {
|
|
167
|
+
alias: 'y',
|
|
168
|
+
type: 'boolean',
|
|
169
|
+
description: 'Skip confirmation prompt',
|
|
170
|
+
default: false,
|
|
171
|
+
})
|
|
172
|
+
.option('log', {
|
|
173
|
+
type: 'string',
|
|
174
|
+
description: 'Path to log file for transfer details',
|
|
175
|
+
})
|
|
176
|
+
.option('retries', {
|
|
177
|
+
type: 'number',
|
|
178
|
+
description: 'Number of retries on error (default: 3)',
|
|
179
|
+
default: 3,
|
|
180
|
+
})
|
|
181
|
+
.option('quiet', {
|
|
182
|
+
alias: 'q',
|
|
183
|
+
type: 'boolean',
|
|
184
|
+
description: 'Minimal output (no progress bar)',
|
|
185
|
+
default: false,
|
|
186
|
+
})
|
|
187
|
+
.option('where', {
|
|
188
|
+
alias: 'w',
|
|
189
|
+
type: 'array',
|
|
190
|
+
description: 'Filter documents (e.g., --where "status == active")',
|
|
191
|
+
})
|
|
192
|
+
.option('exclude', {
|
|
193
|
+
alias: 'x',
|
|
194
|
+
type: 'array',
|
|
195
|
+
description: 'Exclude subcollections by pattern (e.g., --exclude "logs" "temp/*")',
|
|
196
|
+
})
|
|
197
|
+
.option('merge', {
|
|
198
|
+
alias: 'm',
|
|
199
|
+
type: 'boolean',
|
|
200
|
+
description: 'Merge documents instead of overwriting',
|
|
201
|
+
default: false,
|
|
202
|
+
})
|
|
203
|
+
.option('parallel', {
|
|
204
|
+
alias: 'p',
|
|
205
|
+
type: 'number',
|
|
206
|
+
description: 'Number of parallel collection transfers (default: 1)',
|
|
207
|
+
default: 1,
|
|
208
|
+
})
|
|
209
|
+
.option('clear', {
|
|
210
|
+
type: 'boolean',
|
|
211
|
+
description: 'Clear destination collections before transfer (DESTRUCTIVE)',
|
|
212
|
+
default: false,
|
|
213
|
+
})
|
|
214
|
+
.option('delete-missing', {
|
|
215
|
+
type: 'boolean',
|
|
216
|
+
description: 'Delete destination docs not present in source (sync mode)',
|
|
217
|
+
default: false,
|
|
218
|
+
})
|
|
219
|
+
.option('interactive', {
|
|
220
|
+
alias: 'i',
|
|
221
|
+
type: 'boolean',
|
|
222
|
+
description: 'Interactive mode with prompts for project and collection selection',
|
|
223
|
+
default: false,
|
|
224
|
+
})
|
|
225
|
+
.option('transform', {
|
|
226
|
+
alias: 't',
|
|
227
|
+
type: 'string',
|
|
228
|
+
description: 'Path to JS/TS file exporting a transform(doc, meta) function',
|
|
229
|
+
})
|
|
230
|
+
.option('rename-collection', {
|
|
231
|
+
alias: 'r',
|
|
232
|
+
type: 'array',
|
|
233
|
+
description: 'Rename collection in destination (format: source:dest)',
|
|
234
|
+
})
|
|
235
|
+
.option('id-prefix', {
|
|
236
|
+
type: 'string',
|
|
237
|
+
description: 'Add prefix to document IDs in destination',
|
|
238
|
+
})
|
|
239
|
+
.option('id-suffix', {
|
|
240
|
+
type: 'string',
|
|
241
|
+
description: 'Add suffix to document IDs in destination',
|
|
242
|
+
})
|
|
243
|
+
.option('webhook', {
|
|
244
|
+
type: 'string',
|
|
245
|
+
description: 'Webhook URL for transfer notifications (Slack, Discord, or custom)',
|
|
246
|
+
})
|
|
247
|
+
.option('resume', {
|
|
248
|
+
type: 'boolean',
|
|
249
|
+
description: 'Resume an interrupted transfer from saved state',
|
|
250
|
+
default: false,
|
|
251
|
+
})
|
|
252
|
+
.option('state-file', {
|
|
253
|
+
type: 'string',
|
|
254
|
+
description: 'Path to state file for resume (default: .fscopy-state.json)',
|
|
255
|
+
default: '.fscopy-state.json',
|
|
256
|
+
})
|
|
257
|
+
.example('$0 --init config.ini', 'Generate INI config template (default)')
|
|
258
|
+
.example('$0 --init config.json', 'Generate JSON config template')
|
|
259
|
+
.example('$0 -f config.ini', 'Run transfer with config file')
|
|
260
|
+
.example('$0 -f config.ini -d false -y', 'Live transfer, skip confirmation')
|
|
261
|
+
.example('$0 -f config.ini --log transfer.log', 'Transfer with logging')
|
|
262
|
+
.example('$0 -f config.ini --where "active == true"', 'Filter documents')
|
|
263
|
+
.example('$0 -f config.ini --exclude "logs" --exclude "cache"', 'Exclude subcollections')
|
|
264
|
+
.example('$0 -f config.ini --merge', 'Merge instead of overwrite')
|
|
265
|
+
.example('$0 -f config.ini --parallel 3', 'Transfer 3 collections in parallel')
|
|
266
|
+
.example('$0 -f config.ini --clear', 'Clear destination before transfer')
|
|
267
|
+
.example('$0 -f config.ini --delete-missing', 'Sync mode: delete orphan docs in dest')
|
|
268
|
+
.example('$0 -i', 'Interactive mode with prompts')
|
|
269
|
+
.example('$0 -f config.ini -t ./transform.ts', 'Transform documents during transfer')
|
|
270
|
+
.example('$0 -f config.ini -r users:users_backup', 'Rename collection in destination')
|
|
271
|
+
.example('$0 -f config.ini --id-prefix backup_', 'Add prefix to document IDs')
|
|
272
|
+
.example('$0 -f config.ini --webhook https://hooks.slack.com/...', 'Send notification to Slack')
|
|
273
|
+
.example('$0 -f config.ini --resume', 'Resume an interrupted transfer')
|
|
274
|
+
.help()
|
|
275
|
+
.parseSync() as CliArgs;
|
|
276
|
+
|
|
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
|
+
|
|
780
|
+
// =============================================================================
|
|
781
|
+
// Transform Loading
|
|
782
|
+
// =============================================================================
|
|
783
|
+
|
|
784
|
+
async function loadTransformFunction(transformPath: string): Promise<TransformFunction> {
|
|
785
|
+
const absolutePath = path.resolve(transformPath);
|
|
786
|
+
|
|
787
|
+
if (!fs.existsSync(absolutePath)) {
|
|
788
|
+
throw new Error(`Transform file not found: ${absolutePath}`);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
try {
|
|
792
|
+
const module = await import(absolutePath);
|
|
793
|
+
|
|
794
|
+
// Look for 'transform' export (default or named)
|
|
795
|
+
const transformFn = module.default?.transform ?? module.transform ?? module.default;
|
|
796
|
+
|
|
797
|
+
if (typeof transformFn !== 'function') {
|
|
798
|
+
throw new Error(
|
|
799
|
+
`Transform file must export a 'transform' function. Got: ${typeof transformFn}`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return transformFn as TransformFunction;
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if ((error as Error).message.includes('Transform file')) {
|
|
806
|
+
throw error;
|
|
807
|
+
}
|
|
808
|
+
throw new Error(`Failed to load transform file: ${(error as Error).message}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// =============================================================================
|
|
813
|
+
// Display & Confirmation
|
|
814
|
+
// =============================================================================
|
|
815
|
+
|
|
816
|
+
function displayConfig(config: Config): void {
|
|
817
|
+
console.log('='.repeat(60));
|
|
818
|
+
console.log('š FSCOPY - CONFIGURATION');
|
|
819
|
+
console.log('='.repeat(60));
|
|
820
|
+
console.log('');
|
|
821
|
+
console.log(` š¤ Source project: ${config.sourceProject || '(not set)'}`);
|
|
822
|
+
console.log(` š„ Destination project: ${config.destProject || '(not set)'}`);
|
|
823
|
+
console.log('');
|
|
824
|
+
console.log(
|
|
825
|
+
` š Collections: ${config.collections.length > 0 ? config.collections.join(', ') : '(none)'}`
|
|
826
|
+
);
|
|
827
|
+
console.log(` š Include subcollections: ${config.includeSubcollections}`);
|
|
828
|
+
console.log(` š¢ Document limit: ${config.limit === 0 ? 'No limit' : config.limit}`);
|
|
829
|
+
console.log(` š¦ Batch size: ${config.batchSize}`);
|
|
830
|
+
console.log(` š Retries on error: ${config.retries}`);
|
|
831
|
+
|
|
832
|
+
// New options
|
|
833
|
+
if (config.where.length > 0) {
|
|
834
|
+
const whereStr = config.where.map((w) => `${w.field} ${w.operator} ${w.value}`).join(', ');
|
|
835
|
+
console.log(` š Where filters: ${whereStr}`);
|
|
836
|
+
}
|
|
837
|
+
if (config.exclude.length > 0) {
|
|
838
|
+
console.log(` š« Exclude patterns: ${config.exclude.join(', ')}`);
|
|
839
|
+
}
|
|
840
|
+
if (config.merge) {
|
|
841
|
+
console.log(` š Merge mode: enabled (merge instead of overwrite)`);
|
|
842
|
+
}
|
|
843
|
+
if (config.parallel > 1) {
|
|
844
|
+
console.log(` ā” Parallel transfers: ${config.parallel} collections`);
|
|
845
|
+
}
|
|
846
|
+
if (config.clear) {
|
|
847
|
+
console.log(` šļø Clear destination: enabled (DESTRUCTIVE)`);
|
|
848
|
+
}
|
|
849
|
+
if (config.deleteMissing) {
|
|
850
|
+
console.log(` š Delete missing: enabled (sync mode)`);
|
|
851
|
+
}
|
|
852
|
+
if (config.transform) {
|
|
853
|
+
console.log(` š§ Transform: ${config.transform}`);
|
|
854
|
+
}
|
|
855
|
+
if (Object.keys(config.renameCollection).length > 0) {
|
|
856
|
+
const renameStr = Object.entries(config.renameCollection)
|
|
857
|
+
.map(([src, dest]) => `${src}ā${dest}`)
|
|
858
|
+
.join(', ');
|
|
859
|
+
console.log(` š Rename collections: ${renameStr}`);
|
|
860
|
+
}
|
|
861
|
+
if (config.idPrefix || config.idSuffix) {
|
|
862
|
+
const idMod = [
|
|
863
|
+
config.idPrefix ? `prefix: "${config.idPrefix}"` : null,
|
|
864
|
+
config.idSuffix ? `suffix: "${config.idSuffix}"` : null,
|
|
865
|
+
]
|
|
866
|
+
.filter(Boolean)
|
|
867
|
+
.join(', ');
|
|
868
|
+
console.log(` š·ļø ID modification: ${idMod}`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
console.log('');
|
|
872
|
+
|
|
873
|
+
if (config.dryRun) {
|
|
874
|
+
console.log(' š Mode: DRY RUN (no data will be written)');
|
|
875
|
+
} else {
|
|
876
|
+
console.log(' ā” Mode: LIVE (data WILL be transferred)');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log('');
|
|
880
|
+
console.log('='.repeat(60));
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function askConfirmation(config: Config): Promise<boolean> {
|
|
884
|
+
const rl = readline.createInterface({
|
|
885
|
+
input: process.stdin,
|
|
886
|
+
output: process.stdout,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
return new Promise((resolve) => {
|
|
890
|
+
const modeText = config.dryRun ? 'DRY RUN' : 'ā ļø LIVE TRANSFER';
|
|
891
|
+
rl.question(`\nProceed with ${modeText}? (y/N): `, (answer) => {
|
|
892
|
+
rl.close();
|
|
893
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
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
|
+
// =============================================================================
|
|
1009
|
+
// Firebase
|
|
1010
|
+
// =============================================================================
|
|
1011
|
+
|
|
1012
|
+
let sourceApp: admin.app.App | null = null;
|
|
1013
|
+
let destApp: admin.app.App | null = null;
|
|
1014
|
+
|
|
1015
|
+
function initializeFirebase(config: Config): { sourceDb: Firestore; destDb: Firestore } {
|
|
1016
|
+
sourceApp = admin.initializeApp(
|
|
1017
|
+
{
|
|
1018
|
+
credential: admin.credential.applicationDefault(),
|
|
1019
|
+
projectId: config.sourceProject!,
|
|
1020
|
+
},
|
|
1021
|
+
'source'
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
destApp = admin.initializeApp(
|
|
1025
|
+
{
|
|
1026
|
+
credential: admin.credential.applicationDefault(),
|
|
1027
|
+
projectId: config.destProject!,
|
|
1028
|
+
},
|
|
1029
|
+
'dest'
|
|
1030
|
+
);
|
|
1031
|
+
|
|
1032
|
+
return {
|
|
1033
|
+
sourceDb: sourceApp.firestore(),
|
|
1034
|
+
destDb: destApp.firestore(),
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
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
|
|
1173
|
+
): 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
|
+
// =============================================================================
|
|
1212
|
+
|
|
1213
|
+
const STATE_VERSION = 1;
|
|
1214
|
+
|
|
1215
|
+
function loadTransferState(stateFile: string): TransferState | null {
|
|
1216
|
+
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;
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
console.error(`ā ļø Failed to load state file: ${(error as Error).message}`);
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
|
|
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);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
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
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
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;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// =============================================================================
|
|
1686
|
+
// Main
|
|
1687
|
+
// =============================================================================
|
|
1688
|
+
|
|
1689
|
+
// Handle --init command
|
|
1690
|
+
if (argv.init !== undefined) {
|
|
1691
|
+
const filename = argv.init || 'fscopy.ini';
|
|
1692
|
+
generateConfigFile(filename);
|
|
1693
|
+
process.exit(0);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Main transfer flow
|
|
1697
|
+
let config: Config = defaults;
|
|
1698
|
+
let logger: Logger | null = null;
|
|
1699
|
+
let stats: Stats = { collectionsProcessed: 0, documentsTransferred: 0, documentsDeleted: 0, errors: 0 };
|
|
1700
|
+
let startTime = Date.now();
|
|
1701
|
+
|
|
1702
|
+
try {
|
|
1703
|
+
const fileConfig = loadConfigFile(argv.config);
|
|
1704
|
+
config = mergeConfig(defaults, fileConfig, argv);
|
|
1705
|
+
|
|
1706
|
+
// Run interactive mode if enabled
|
|
1707
|
+
if (argv.interactive) {
|
|
1708
|
+
config = await runInteractiveMode(config);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
displayConfig(config);
|
|
1712
|
+
|
|
1713
|
+
const errors = validateConfig(config);
|
|
1714
|
+
if (errors.length > 0) {
|
|
1715
|
+
console.log('\nā Configuration errors:');
|
|
1716
|
+
errors.forEach((err) => console.log(` - ${err}`));
|
|
1717
|
+
process.exit(1);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Skip confirmation in interactive mode (already confirmed by selection)
|
|
1721
|
+
if (!argv.yes && !argv.interactive) {
|
|
1722
|
+
const confirmed = await askConfirmation(config);
|
|
1723
|
+
if (!confirmed) {
|
|
1724
|
+
console.log('\nš« Transfer cancelled by user\n');
|
|
1725
|
+
process.exit(0);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
logger = new Logger(argv.log);
|
|
1730
|
+
logger.init();
|
|
1731
|
+
logger.info('Transfer started', { config: config as unknown as Record<string, unknown> });
|
|
1732
|
+
|
|
1733
|
+
// Handle resume mode
|
|
1734
|
+
let transferState: TransferState | null = null;
|
|
1735
|
+
if (config.resume) {
|
|
1736
|
+
const existingState = loadTransferState(config.stateFile);
|
|
1737
|
+
if (!existingState) {
|
|
1738
|
+
console.error(`\nā No state file found at ${config.stateFile}`);
|
|
1739
|
+
console.error(' Cannot resume without a saved state. Run without --resume to start fresh.');
|
|
1740
|
+
process.exit(1);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const stateErrors = validateStateForResume(existingState, config);
|
|
1744
|
+
if (stateErrors.length > 0) {
|
|
1745
|
+
console.error('\nā Cannot resume: state file incompatible with current config:');
|
|
1746
|
+
stateErrors.forEach((err) => console.error(` - ${err}`));
|
|
1747
|
+
process.exit(1);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
transferState = existingState;
|
|
1751
|
+
const completedCount = Object.values(transferState.completedDocs).reduce((sum, ids) => sum + ids.length, 0);
|
|
1752
|
+
console.log(`\nš Resuming transfer from ${config.stateFile}`);
|
|
1753
|
+
console.log(` Started: ${transferState.startedAt}`);
|
|
1754
|
+
console.log(` Previously completed: ${completedCount} documents`);
|
|
1755
|
+
stats = { ...transferState.stats };
|
|
1756
|
+
} else if (!config.dryRun) {
|
|
1757
|
+
// Create new state for tracking (only in non-dry-run mode)
|
|
1758
|
+
transferState = createInitialState(config);
|
|
1759
|
+
saveTransferState(config.stateFile, transferState);
|
|
1760
|
+
console.log(`\nš¾ State will be saved to ${config.stateFile} (use --resume to continue if interrupted)`);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Load transform function if specified
|
|
1764
|
+
let transformFn: TransformFunction | null = null;
|
|
1765
|
+
if (config.transform) {
|
|
1766
|
+
console.log(`\nš§ Loading transform: ${config.transform}`);
|
|
1767
|
+
transformFn = await loadTransformFunction(config.transform);
|
|
1768
|
+
console.log(' Transform loaded successfully');
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
console.log('\n');
|
|
1772
|
+
startTime = Date.now();
|
|
1773
|
+
|
|
1774
|
+
const { sourceDb, destDb } = initializeFirebase(config);
|
|
1775
|
+
|
|
1776
|
+
if (!config.resume) {
|
|
1777
|
+
stats = {
|
|
1778
|
+
collectionsProcessed: 0,
|
|
1779
|
+
documentsTransferred: 0,
|
|
1780
|
+
documentsDeleted: 0,
|
|
1781
|
+
errors: 0,
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
// Count total documents for progress bar
|
|
1786
|
+
let totalDocs = 0;
|
|
1787
|
+
let progressBar: cliProgress.SingleBar | null = null;
|
|
1788
|
+
|
|
1789
|
+
if (!argv.quiet) {
|
|
1790
|
+
console.log('š Counting documents...');
|
|
1791
|
+
for (const collection of config.collections) {
|
|
1792
|
+
totalDocs += await countDocuments(sourceDb, collection, config);
|
|
1793
|
+
}
|
|
1794
|
+
console.log(` Found ${totalDocs} documents to transfer\n`);
|
|
1795
|
+
|
|
1796
|
+
if (totalDocs > 0) {
|
|
1797
|
+
progressBar = new cliProgress.SingleBar({
|
|
1798
|
+
format: 'š¦ Progress |{bar}| {percentage}% | {value}/{total} docs | ETA: {eta}s',
|
|
1799
|
+
barCompleteChar: 'ā',
|
|
1800
|
+
barIncompleteChar: 'ā',
|
|
1801
|
+
hideCursor: true,
|
|
1802
|
+
});
|
|
1803
|
+
progressBar.start(totalDocs, 0);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// Clear destination collections if enabled
|
|
1808
|
+
if (config.clear) {
|
|
1809
|
+
console.log('šļø Clearing destination collections...');
|
|
1810
|
+
for (const collection of config.collections) {
|
|
1811
|
+
const destCollection = getDestCollectionPath(collection, config.renameCollection);
|
|
1812
|
+
const deleted = await clearCollection(
|
|
1813
|
+
destDb,
|
|
1814
|
+
destCollection,
|
|
1815
|
+
config,
|
|
1816
|
+
logger,
|
|
1817
|
+
config.includeSubcollections
|
|
1818
|
+
);
|
|
1819
|
+
stats.documentsDeleted += deleted;
|
|
1820
|
+
}
|
|
1821
|
+
console.log(` Deleted ${stats.documentsDeleted} documents\n`);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
const ctx: TransferContext = { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state: transferState };
|
|
1825
|
+
|
|
1826
|
+
// Transfer collections (with optional parallelism)
|
|
1827
|
+
if (config.parallel > 1) {
|
|
1828
|
+
await processInParallel(config.collections, config.parallel, (collection) =>
|
|
1829
|
+
transferCollection(ctx, collection)
|
|
1830
|
+
);
|
|
1831
|
+
} else {
|
|
1832
|
+
for (const collection of config.collections) {
|
|
1833
|
+
await transferCollection(ctx, collection);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
if (progressBar) {
|
|
1838
|
+
progressBar.stop();
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// Delete orphan documents if enabled (sync mode)
|
|
1842
|
+
if (config.deleteMissing) {
|
|
1843
|
+
console.log('\nš Deleting orphan documents (sync mode)...');
|
|
1844
|
+
for (const collection of config.collections) {
|
|
1845
|
+
const deleted = await deleteOrphanDocuments(
|
|
1846
|
+
sourceDb,
|
|
1847
|
+
destDb,
|
|
1848
|
+
collection,
|
|
1849
|
+
config,
|
|
1850
|
+
logger
|
|
1851
|
+
);
|
|
1852
|
+
stats.documentsDeleted += deleted;
|
|
1853
|
+
}
|
|
1854
|
+
if (stats.documentsDeleted > 0) {
|
|
1855
|
+
console.log(` Deleted ${stats.documentsDeleted} orphan documents`);
|
|
1856
|
+
} else {
|
|
1857
|
+
console.log(' No orphan documents found');
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
1862
|
+
|
|
1863
|
+
logger.success('Transfer completed', {
|
|
1864
|
+
stats: stats as unknown as Record<string, unknown>,
|
|
1865
|
+
duration,
|
|
1866
|
+
});
|
|
1867
|
+
logger.summary(stats, duration);
|
|
1868
|
+
|
|
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}`);
|
|
1875
|
+
}
|
|
1876
|
+
console.log(`Documents transferred: ${stats.documentsTransferred}`);
|
|
1877
|
+
console.log(`Errors: ${stats.errors}`);
|
|
1878
|
+
console.log(`Duration: ${duration}s`);
|
|
1879
|
+
|
|
1880
|
+
if (argv.log) {
|
|
1881
|
+
console.log(`Log file: ${argv.log}`);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
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');
|
|
1887
|
+
} else {
|
|
1888
|
+
console.log('\nā Transfer completed successfully');
|
|
1889
|
+
// Delete state file on successful completion
|
|
1890
|
+
deleteTransferState(config.stateFile);
|
|
1891
|
+
}
|
|
1892
|
+
console.log('='.repeat(60) + '\n');
|
|
1893
|
+
|
|
1894
|
+
// Send webhook notification if configured
|
|
1895
|
+
if (config.webhook) {
|
|
1896
|
+
await sendWebhook(
|
|
1897
|
+
config.webhook,
|
|
1898
|
+
{
|
|
1899
|
+
source: config.sourceProject!,
|
|
1900
|
+
destination: config.destProject!,
|
|
1901
|
+
collections: config.collections,
|
|
1902
|
+
stats,
|
|
1903
|
+
duration: Number.parseFloat(duration),
|
|
1904
|
+
dryRun: config.dryRun,
|
|
1905
|
+
success: true,
|
|
1906
|
+
},
|
|
1907
|
+
logger
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
await cleanupFirebase();
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
const errorMessage = (error as Error).message;
|
|
1914
|
+
console.error('\nā Error during transfer:', errorMessage);
|
|
1915
|
+
|
|
1916
|
+
// Send webhook notification on error if configured
|
|
1917
|
+
if (config.webhook && logger) {
|
|
1918
|
+
await sendWebhook(
|
|
1919
|
+
config.webhook,
|
|
1920
|
+
{
|
|
1921
|
+
source: config.sourceProject ?? 'unknown',
|
|
1922
|
+
destination: config.destProject ?? 'unknown',
|
|
1923
|
+
collections: config.collections,
|
|
1924
|
+
stats,
|
|
1925
|
+
duration: Number.parseFloat(((Date.now() - startTime) / 1000).toFixed(2)),
|
|
1926
|
+
dryRun: config.dryRun,
|
|
1927
|
+
success: false,
|
|
1928
|
+
error: errorMessage,
|
|
1929
|
+
},
|
|
1930
|
+
logger
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
await cleanupFirebase();
|
|
1935
|
+
process.exit(1);
|
|
1936
|
+
}
|