@fazetitans/fscopy 1.3.0 → 1.4.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/package.json +1 -1
- package/src/cli.ts +5 -12
- package/src/config/parser.ts +41 -7
- package/src/interactive.ts +520 -35
- package/src/orchestrator.ts +81 -27
- package/src/transfer/count.ts +10 -1
- package/src/transfer/transfer.ts +43 -21
- package/src/types.ts +1 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -89,7 +89,6 @@ const argv = yargs(hideBin(process.argv))
|
|
|
89
89
|
.option('retries', {
|
|
90
90
|
type: 'number',
|
|
91
91
|
description: 'Number of retries on error (default: 3)',
|
|
92
|
-
default: 3,
|
|
93
92
|
})
|
|
94
93
|
.option('quiet', {
|
|
95
94
|
alias: 'q',
|
|
@@ -111,23 +110,19 @@ const argv = yargs(hideBin(process.argv))
|
|
|
111
110
|
alias: 'm',
|
|
112
111
|
type: 'boolean',
|
|
113
112
|
description: 'Merge documents instead of overwriting',
|
|
114
|
-
default: false,
|
|
115
113
|
})
|
|
116
114
|
.option('parallel', {
|
|
117
115
|
alias: 'p',
|
|
118
116
|
type: 'number',
|
|
119
117
|
description: 'Number of parallel collection transfers (default: 1)',
|
|
120
|
-
default: 1,
|
|
121
118
|
})
|
|
122
119
|
.option('clear', {
|
|
123
120
|
type: 'boolean',
|
|
124
121
|
description: 'Clear destination collections before transfer (DESTRUCTIVE)',
|
|
125
|
-
default: false,
|
|
126
122
|
})
|
|
127
123
|
.option('delete-missing', {
|
|
128
124
|
type: 'boolean',
|
|
129
125
|
description: 'Delete destination docs not present in source (sync mode)',
|
|
130
|
-
default: false,
|
|
131
126
|
})
|
|
132
127
|
.option('interactive', {
|
|
133
128
|
alias: 'i',
|
|
@@ -170,17 +165,14 @@ const argv = yargs(hideBin(process.argv))
|
|
|
170
165
|
.option('verify', {
|
|
171
166
|
type: 'boolean',
|
|
172
167
|
description: 'Verify document counts after transfer',
|
|
173
|
-
default: false,
|
|
174
168
|
})
|
|
175
169
|
.option('rate-limit', {
|
|
176
170
|
type: 'number',
|
|
177
171
|
description: 'Limit transfer rate (documents per second, 0 = unlimited)',
|
|
178
|
-
default: 0,
|
|
179
172
|
})
|
|
180
173
|
.option('skip-oversized', {
|
|
181
174
|
type: 'boolean',
|
|
182
175
|
description: 'Skip documents exceeding 1MB instead of failing',
|
|
183
|
-
default: false,
|
|
184
176
|
})
|
|
185
177
|
.option('json', {
|
|
186
178
|
type: 'boolean',
|
|
@@ -195,17 +187,14 @@ const argv = yargs(hideBin(process.argv))
|
|
|
195
187
|
.option('detect-conflicts', {
|
|
196
188
|
type: 'boolean',
|
|
197
189
|
description: 'Detect if destination docs were modified during transfer',
|
|
198
|
-
default: false,
|
|
199
190
|
})
|
|
200
191
|
.option('max-depth', {
|
|
201
192
|
type: 'number',
|
|
202
193
|
description: 'Max subcollection depth (0 = unlimited)',
|
|
203
|
-
default: 0,
|
|
204
194
|
})
|
|
205
195
|
.option('verify-integrity', {
|
|
206
196
|
type: 'boolean',
|
|
207
197
|
description: 'Verify document integrity with hash after transfer',
|
|
208
|
-
default: false,
|
|
209
198
|
})
|
|
210
199
|
.option('validate-only', {
|
|
211
200
|
type: 'boolean',
|
|
@@ -254,7 +243,11 @@ async function main(): Promise<void> {
|
|
|
254
243
|
|
|
255
244
|
// Run interactive mode if enabled
|
|
256
245
|
if (argv.interactive) {
|
|
257
|
-
|
|
246
|
+
const result = await runInteractiveMode(config);
|
|
247
|
+
config = result.config;
|
|
248
|
+
if (result.action === 'save') {
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
258
251
|
}
|
|
259
252
|
|
|
260
253
|
displayConfig(config);
|
package/src/config/parser.ts
CHANGED
|
@@ -66,6 +66,12 @@ export function parseStringList(value: string | undefined): string[] {
|
|
|
66
66
|
.filter((s) => s.length > 0);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
export function parseIntOption(value: string | undefined): number | undefined {
|
|
70
|
+
if (value === undefined || value === '') return undefined;
|
|
71
|
+
const parsed = Number.parseInt(value, 10);
|
|
72
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
export function parseRenameMapping(
|
|
70
76
|
mappings: string[] | string | undefined
|
|
71
77
|
): Record<string, string> {
|
|
@@ -115,6 +121,13 @@ export function parseIniConfig(content: string): Partial<Config> {
|
|
|
115
121
|
idPrefix?: string;
|
|
116
122
|
idSuffix?: string;
|
|
117
123
|
webhook?: string;
|
|
124
|
+
retries?: string;
|
|
125
|
+
rateLimit?: string;
|
|
126
|
+
skipOversized?: string | boolean;
|
|
127
|
+
detectConflicts?: string | boolean;
|
|
128
|
+
maxDepth?: string;
|
|
129
|
+
verify?: string | boolean;
|
|
130
|
+
verifyIntegrity?: string | boolean;
|
|
118
131
|
};
|
|
119
132
|
};
|
|
120
133
|
|
|
@@ -149,6 +162,13 @@ export function parseIniConfig(content: string): Partial<Config> {
|
|
|
149
162
|
idPrefix: parsed.options?.idPrefix ?? null,
|
|
150
163
|
idSuffix: parsed.options?.idSuffix ?? null,
|
|
151
164
|
webhook: parsed.options?.webhook ?? null,
|
|
165
|
+
retries: parseIntOption(parsed.options?.retries),
|
|
166
|
+
rateLimit: parseIntOption(parsed.options?.rateLimit),
|
|
167
|
+
skipOversized: parsed.options?.skipOversized !== undefined ? parseBoolean(parsed.options.skipOversized) : undefined,
|
|
168
|
+
detectConflicts: parsed.options?.detectConflicts !== undefined ? parseBoolean(parsed.options.detectConflicts) : undefined,
|
|
169
|
+
maxDepth: parseIntOption(parsed.options?.maxDepth),
|
|
170
|
+
verify: parsed.options?.verify !== undefined ? parseBoolean(parsed.options.verify) : undefined,
|
|
171
|
+
verifyIntegrity: parsed.options?.verifyIntegrity !== undefined ? parseBoolean(parsed.options.verifyIntegrity) : undefined,
|
|
152
172
|
};
|
|
153
173
|
}
|
|
154
174
|
|
|
@@ -172,6 +192,13 @@ export function parseJsonConfig(content: string): Partial<Config> {
|
|
|
172
192
|
idPrefix?: string;
|
|
173
193
|
idSuffix?: string;
|
|
174
194
|
webhook?: string;
|
|
195
|
+
retries?: number;
|
|
196
|
+
rateLimit?: number;
|
|
197
|
+
skipOversized?: boolean;
|
|
198
|
+
detectConflicts?: boolean;
|
|
199
|
+
maxDepth?: number;
|
|
200
|
+
verify?: boolean;
|
|
201
|
+
verifyIntegrity?: boolean;
|
|
175
202
|
};
|
|
176
203
|
|
|
177
204
|
return {
|
|
@@ -193,6 +220,13 @@ export function parseJsonConfig(content: string): Partial<Config> {
|
|
|
193
220
|
idPrefix: config.idPrefix ?? null,
|
|
194
221
|
idSuffix: config.idSuffix ?? null,
|
|
195
222
|
webhook: config.webhook ?? null,
|
|
223
|
+
retries: config.retries,
|
|
224
|
+
rateLimit: config.rateLimit,
|
|
225
|
+
skipOversized: config.skipOversized,
|
|
226
|
+
detectConflicts: config.detectConflicts,
|
|
227
|
+
maxDepth: config.maxDepth,
|
|
228
|
+
verify: config.verify,
|
|
229
|
+
verifyIntegrity: config.verifyIntegrity,
|
|
196
230
|
};
|
|
197
231
|
}
|
|
198
232
|
|
|
@@ -232,7 +266,6 @@ export function mergeConfig(
|
|
|
232
266
|
sourceProject:
|
|
233
267
|
cliArgs.sourceProject ?? fileConfig.sourceProject ?? defaultConfig.sourceProject,
|
|
234
268
|
destProject: cliArgs.destProject ?? fileConfig.destProject ?? defaultConfig.destProject,
|
|
235
|
-
retries: cliArgs.retries ?? defaultConfig.retries,
|
|
236
269
|
where:
|
|
237
270
|
cliWhereFilters.length > 0
|
|
238
271
|
? cliWhereFilters
|
|
@@ -251,15 +284,16 @@ export function mergeConfig(
|
|
|
251
284
|
idPrefix: cliArgs.idPrefix ?? fileConfig.idPrefix ?? defaultConfig.idPrefix,
|
|
252
285
|
idSuffix: cliArgs.idSuffix ?? fileConfig.idSuffix ?? defaultConfig.idSuffix,
|
|
253
286
|
webhook: cliArgs.webhook ?? fileConfig.webhook ?? defaultConfig.webhook,
|
|
287
|
+
retries: cliArgs.retries ?? fileConfig.retries ?? defaultConfig.retries,
|
|
254
288
|
resume: cliArgs.resume ?? defaultConfig.resume,
|
|
255
289
|
stateFile: cliArgs.stateFile ?? defaultConfig.stateFile,
|
|
256
|
-
verify: cliArgs.verify ?? defaultConfig.verify,
|
|
257
|
-
rateLimit: cliArgs.rateLimit ?? defaultConfig.rateLimit,
|
|
258
|
-
skipOversized: cliArgs.skipOversized ?? defaultConfig.skipOversized,
|
|
290
|
+
verify: cliArgs.verify ?? fileConfig.verify ?? defaultConfig.verify,
|
|
291
|
+
rateLimit: cliArgs.rateLimit ?? fileConfig.rateLimit ?? defaultConfig.rateLimit,
|
|
292
|
+
skipOversized: cliArgs.skipOversized ?? fileConfig.skipOversized ?? defaultConfig.skipOversized,
|
|
259
293
|
json: cliArgs.json ?? defaultConfig.json,
|
|
260
294
|
transformSamples: cliArgs.transformSamples ?? defaultConfig.transformSamples,
|
|
261
|
-
detectConflicts: cliArgs.detectConflicts ?? defaultConfig.detectConflicts,
|
|
262
|
-
maxDepth: cliArgs.maxDepth ?? defaultConfig.maxDepth,
|
|
263
|
-
verifyIntegrity: cliArgs.verifyIntegrity ?? defaultConfig.verifyIntegrity,
|
|
295
|
+
detectConflicts: cliArgs.detectConflicts ?? fileConfig.detectConflicts ?? defaultConfig.detectConflicts,
|
|
296
|
+
maxDepth: cliArgs.maxDepth ?? fileConfig.maxDepth ?? defaultConfig.maxDepth,
|
|
297
|
+
verifyIntegrity: cliArgs.verifyIntegrity ?? fileConfig.verifyIntegrity ?? defaultConfig.verifyIntegrity,
|
|
264
298
|
};
|
|
265
299
|
}
|
package/src/interactive.ts
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import admin from 'firebase-admin';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import type { Firestore } from 'firebase-admin/firestore';
|
|
3
|
-
import { input, checkbox, confirm } from '@inquirer/prompts';
|
|
5
|
+
import { input, checkbox, confirm, select, number } from '@inquirer/prompts';
|
|
4
6
|
import { SEPARATOR_LENGTH } from './constants.js';
|
|
5
|
-
import type { Config } from './types.js';
|
|
7
|
+
import type { Config, WhereFilter } from './types.js';
|
|
8
|
+
import { parseWhereFilter, parseRenameMapping, parseStringList } from './config/parser.js';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export interface InteractiveResult {
|
|
15
|
+
config: Config;
|
|
16
|
+
action: 'execute' | 'save';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type AdvancedOption =
|
|
20
|
+
| 'exclude' | 'where' | 'parallel' | 'batchSize' | 'limit'
|
|
21
|
+
| 'maxDepth' | 'rateLimit' | 'clear' | 'deleteMissing' | 'transform'
|
|
22
|
+
| 'renameCollection' | 'idPrefix' | 'idSuffix' | 'webhook'
|
|
23
|
+
| 'skipOversized' | 'detectConflicts' | 'verify' | 'verifyIntegrity'
|
|
24
|
+
| 'retries';
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Project prompts
|
|
28
|
+
// =============================================================================
|
|
6
29
|
|
|
7
30
|
async function promptForProject(
|
|
8
31
|
currentValue: string | null | undefined,
|
|
@@ -23,7 +46,7 @@ async function promptForIdModification(
|
|
|
23
46
|
currentPrefix: string | null,
|
|
24
47
|
currentSuffix: string | null
|
|
25
48
|
): Promise<{ idPrefix: string | null; idSuffix: string | null }> {
|
|
26
|
-
console.log('\
|
|
49
|
+
console.log('\nSource and destination are the same project.');
|
|
27
50
|
console.log(' You need to rename collections or modify document IDs to avoid overwriting.\n');
|
|
28
51
|
|
|
29
52
|
const modifyIds = await confirm({
|
|
@@ -54,30 +77,24 @@ async function promptForIdModification(
|
|
|
54
77
|
return { idPrefix: currentPrefix, idSuffix };
|
|
55
78
|
}
|
|
56
79
|
|
|
57
|
-
console.log('\
|
|
80
|
+
console.log('\nCannot proceed: source and destination are the same without ID modification.');
|
|
58
81
|
console.log(' This would overwrite your data. Use --rename-collection, --id-prefix, or --id-suffix.\n');
|
|
59
82
|
process.exit(1);
|
|
60
83
|
}
|
|
61
84
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
|
|
66
|
-
|
|
67
|
-
const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '📤');
|
|
68
|
-
const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '📥');
|
|
69
|
-
|
|
70
|
-
let idPrefix = config.idPrefix;
|
|
71
|
-
let idSuffix = config.idSuffix;
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// Collection discovery
|
|
87
|
+
// =============================================================================
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
89
|
+
interface CollectionInfo {
|
|
90
|
+
id: string;
|
|
91
|
+
count: number;
|
|
92
|
+
}
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
94
|
+
async function discoverCollections(
|
|
95
|
+
sourceProject: string
|
|
96
|
+
): Promise<{ app: admin.app.App; db: Firestore; collections: CollectionInfo[] }> {
|
|
97
|
+
console.log('\nConnecting to source project...');
|
|
81
98
|
|
|
82
99
|
let tempSourceApp: admin.app.App;
|
|
83
100
|
let sourceDb: Firestore;
|
|
@@ -92,12 +109,10 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
92
109
|
'interactive-source'
|
|
93
110
|
);
|
|
94
111
|
sourceDb = tempSourceApp.firestore();
|
|
95
|
-
|
|
96
|
-
// List collections (also tests connectivity)
|
|
97
112
|
rootCollections = await sourceDb.listCollections();
|
|
98
113
|
} catch (error) {
|
|
99
114
|
const err = error as Error & { code?: string };
|
|
100
|
-
console.error('\
|
|
115
|
+
console.error('\nCannot connect to Firebase project:', err.message);
|
|
101
116
|
|
|
102
117
|
if (err.message.includes('default credentials') || err.message.includes('credential')) {
|
|
103
118
|
console.error('\n Run this command to authenticate:');
|
|
@@ -114,14 +129,13 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
114
129
|
const collectionIds = rootCollections.map((col) => col.id);
|
|
115
130
|
|
|
116
131
|
if (collectionIds.length === 0) {
|
|
117
|
-
console.log('\
|
|
132
|
+
console.log('\nNo collections found in source project');
|
|
118
133
|
await tempSourceApp.delete();
|
|
119
134
|
process.exit(0);
|
|
120
135
|
}
|
|
121
136
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const collectionInfo: { id: string; count: number }[] = [];
|
|
137
|
+
console.log('\nAvailable collections:');
|
|
138
|
+
const collectionInfo: CollectionInfo[] = [];
|
|
125
139
|
for (const id of collectionIds) {
|
|
126
140
|
const snapshot = await sourceDb.collection(id).count().get();
|
|
127
141
|
const count = snapshot.data().count;
|
|
@@ -129,7 +143,462 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
129
143
|
console.log(` - ${id} (${count} documents)`);
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
|
|
146
|
+
return { app: tempSourceApp, db: sourceDb, collections: collectionInfo };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// =============================================================================
|
|
150
|
+
// Advanced options
|
|
151
|
+
// =============================================================================
|
|
152
|
+
|
|
153
|
+
const advancedOptionChoices: Array<{ name: string; value: AdvancedOption }> = [
|
|
154
|
+
{ name: 'Exclude subcollection patterns', value: 'exclude' },
|
|
155
|
+
{ name: 'Where filters (filter source documents)', value: 'where' },
|
|
156
|
+
{ name: 'Parallel transfers', value: 'parallel' },
|
|
157
|
+
{ name: 'Batch size', value: 'batchSize' },
|
|
158
|
+
{ name: 'Document limit per collection', value: 'limit' },
|
|
159
|
+
{ name: 'Max subcollection depth', value: 'maxDepth' },
|
|
160
|
+
{ name: 'Rate limit (docs/sec)', value: 'rateLimit' },
|
|
161
|
+
{ name: 'Clear destination before transfer', value: 'clear' },
|
|
162
|
+
{ name: 'Delete missing docs in destination (sync mode)', value: 'deleteMissing' },
|
|
163
|
+
{ name: 'Transform file (JS/TS)', value: 'transform' },
|
|
164
|
+
{ name: 'Rename collections in destination', value: 'renameCollection' },
|
|
165
|
+
{ name: 'ID prefix', value: 'idPrefix' },
|
|
166
|
+
{ name: 'ID suffix', value: 'idSuffix' },
|
|
167
|
+
{ name: 'Webhook URL (Slack, Discord, custom)', value: 'webhook' },
|
|
168
|
+
{ name: 'Skip oversized documents (>1MB)', value: 'skipOversized' },
|
|
169
|
+
{ name: 'Detect conflicts during transfer', value: 'detectConflicts' },
|
|
170
|
+
{ name: 'Verify counts after transfer', value: 'verify' },
|
|
171
|
+
{ name: 'Verify integrity (hash comparison)', value: 'verifyIntegrity' },
|
|
172
|
+
{ name: 'Retries on error', value: 'retries' },
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
async function promptAdvancedOptions(config: Config): Promise<Partial<Config>> {
|
|
176
|
+
const wantAdvanced = await confirm({
|
|
177
|
+
message: 'Configure additional options?',
|
|
178
|
+
default: false,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!wantAdvanced) return {};
|
|
182
|
+
|
|
183
|
+
console.log('');
|
|
184
|
+
const selected = new Set(
|
|
185
|
+
await checkbox<AdvancedOption>({
|
|
186
|
+
message: 'Select options to configure:',
|
|
187
|
+
choices: advancedOptionChoices,
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (selected.size === 0) return {};
|
|
192
|
+
|
|
193
|
+
console.log('');
|
|
194
|
+
const updates: Partial<Config> = {};
|
|
195
|
+
|
|
196
|
+
if (selected.has('exclude')) {
|
|
197
|
+
const val = await input({
|
|
198
|
+
message: 'Exclude patterns (comma-separated, e.g. "logs, cache*, temp"):',
|
|
199
|
+
default: config.exclude.length > 0 ? config.exclude.join(', ') : undefined,
|
|
200
|
+
});
|
|
201
|
+
updates.exclude = parseStringList(val);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (selected.has('where')) {
|
|
205
|
+
const filters: WhereFilter[] = [];
|
|
206
|
+
let addMore = true;
|
|
207
|
+
while (addMore) {
|
|
208
|
+
const filterStr = await input({
|
|
209
|
+
message: `Where filter${filters.length > 0 ? ' (leave empty to stop)' : ''} (e.g. "status == active"):`,
|
|
210
|
+
});
|
|
211
|
+
if (!filterStr.trim()) break;
|
|
212
|
+
const parsed = parseWhereFilter(filterStr);
|
|
213
|
+
if (parsed) {
|
|
214
|
+
filters.push(parsed);
|
|
215
|
+
console.log(` Added: ${parsed.field} ${parsed.operator} ${parsed.value}`);
|
|
216
|
+
}
|
|
217
|
+
if (filters.length > 0) {
|
|
218
|
+
addMore = await confirm({ message: 'Add another filter?', default: false });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (filters.length > 0) {
|
|
222
|
+
updates.where = filters;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (selected.has('parallel')) {
|
|
227
|
+
const val = await number({
|
|
228
|
+
message: 'Number of parallel collection transfers:',
|
|
229
|
+
default: config.parallel,
|
|
230
|
+
min: 1,
|
|
231
|
+
max: 20,
|
|
232
|
+
step: 1,
|
|
233
|
+
});
|
|
234
|
+
if (val !== undefined) updates.parallel = val;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (selected.has('batchSize')) {
|
|
238
|
+
const val = await number({
|
|
239
|
+
message: 'Batch size (documents per write):',
|
|
240
|
+
default: config.batchSize,
|
|
241
|
+
min: 1,
|
|
242
|
+
max: 500,
|
|
243
|
+
step: 1,
|
|
244
|
+
});
|
|
245
|
+
if (val !== undefined) updates.batchSize = val;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (selected.has('limit')) {
|
|
249
|
+
const val = await number({
|
|
250
|
+
message: 'Document limit per collection (0 = no limit):',
|
|
251
|
+
default: config.limit,
|
|
252
|
+
min: 0,
|
|
253
|
+
step: 1,
|
|
254
|
+
});
|
|
255
|
+
if (val !== undefined) updates.limit = val;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (selected.has('maxDepth')) {
|
|
259
|
+
const val = await number({
|
|
260
|
+
message: 'Max subcollection depth (0 = unlimited):',
|
|
261
|
+
default: config.maxDepth,
|
|
262
|
+
min: 0,
|
|
263
|
+
step: 1,
|
|
264
|
+
});
|
|
265
|
+
if (val !== undefined) updates.maxDepth = val;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (selected.has('rateLimit')) {
|
|
269
|
+
const val = await number({
|
|
270
|
+
message: 'Rate limit in docs/sec (0 = unlimited):',
|
|
271
|
+
default: config.rateLimit,
|
|
272
|
+
min: 0,
|
|
273
|
+
step: 1,
|
|
274
|
+
});
|
|
275
|
+
if (val !== undefined) updates.rateLimit = val;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (selected.has('clear')) {
|
|
279
|
+
updates.clear = await confirm({
|
|
280
|
+
message: 'Clear destination collections before transfer? (DESTRUCTIVE)',
|
|
281
|
+
default: config.clear,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (selected.has('deleteMissing')) {
|
|
286
|
+
updates.deleteMissing = await confirm({
|
|
287
|
+
message: 'Delete docs in destination not present in source? (sync mode)',
|
|
288
|
+
default: config.deleteMissing,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (selected.has('transform')) {
|
|
293
|
+
const val = await input({
|
|
294
|
+
message: 'Path to transform file (JS/TS):',
|
|
295
|
+
default: config.transform ?? undefined,
|
|
296
|
+
validate: (value) => {
|
|
297
|
+
if (!value.trim()) return 'Path is required';
|
|
298
|
+
return true;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
updates.transform = val.trim();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (selected.has('renameCollection')) {
|
|
305
|
+
const val = await input({
|
|
306
|
+
message: 'Rename mappings (e.g. "users:users_backup, orders:orders_v2"):',
|
|
307
|
+
default: Object.entries(config.renameCollection).map(([s, d]) => `${s}:${d}`).join(', ') || undefined,
|
|
308
|
+
});
|
|
309
|
+
updates.renameCollection = parseRenameMapping(parseStringList(val));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (selected.has('idPrefix')) {
|
|
313
|
+
const val = await input({
|
|
314
|
+
message: 'Document ID prefix:',
|
|
315
|
+
default: config.idPrefix ?? undefined,
|
|
316
|
+
});
|
|
317
|
+
updates.idPrefix = val.trim() || null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (selected.has('idSuffix')) {
|
|
321
|
+
const val = await input({
|
|
322
|
+
message: 'Document ID suffix:',
|
|
323
|
+
default: config.idSuffix ?? undefined,
|
|
324
|
+
});
|
|
325
|
+
updates.idSuffix = val.trim() || null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (selected.has('webhook')) {
|
|
329
|
+
const val = await input({
|
|
330
|
+
message: 'Webhook URL:',
|
|
331
|
+
default: config.webhook ?? undefined,
|
|
332
|
+
});
|
|
333
|
+
updates.webhook = val.trim() || null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (selected.has('skipOversized')) {
|
|
337
|
+
updates.skipOversized = await confirm({
|
|
338
|
+
message: 'Skip documents exceeding 1MB instead of failing?',
|
|
339
|
+
default: config.skipOversized,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (selected.has('detectConflicts')) {
|
|
344
|
+
updates.detectConflicts = await confirm({
|
|
345
|
+
message: 'Detect destination modifications during transfer?',
|
|
346
|
+
default: config.detectConflicts,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (selected.has('verify')) {
|
|
351
|
+
updates.verify = await confirm({
|
|
352
|
+
message: 'Verify document counts after transfer?',
|
|
353
|
+
default: config.verify,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (selected.has('verifyIntegrity')) {
|
|
358
|
+
updates.verifyIntegrity = await confirm({
|
|
359
|
+
message: 'Verify document integrity with hash after transfer?',
|
|
360
|
+
default: config.verifyIntegrity,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (selected.has('retries')) {
|
|
365
|
+
const val = await number({
|
|
366
|
+
message: 'Number of retries on error:',
|
|
367
|
+
default: config.retries,
|
|
368
|
+
min: 0,
|
|
369
|
+
max: 10,
|
|
370
|
+
step: 1,
|
|
371
|
+
});
|
|
372
|
+
if (val !== undefined) updates.retries = val;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return updates;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// =============================================================================
|
|
379
|
+
// Final action
|
|
380
|
+
// =============================================================================
|
|
381
|
+
|
|
382
|
+
async function promptFinalAction(): Promise<'execute' | 'save-ini' | 'save-json'> {
|
|
383
|
+
console.log('');
|
|
384
|
+
return select({
|
|
385
|
+
message: 'What would you like to do?',
|
|
386
|
+
choices: [
|
|
387
|
+
{ name: 'Execute transfer', value: 'execute' as const },
|
|
388
|
+
{ name: 'Save as INI config file', value: 'save-ini' as const },
|
|
389
|
+
{ name: 'Save as JSON config file', value: 'save-json' as const },
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// =============================================================================
|
|
395
|
+
// Config serialization
|
|
396
|
+
// =============================================================================
|
|
397
|
+
|
|
398
|
+
function serializeWhereFilters(filters: WhereFilter[]): string[] {
|
|
399
|
+
return filters.map((f) => `${f.field} ${f.operator} ${f.value}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function serializeRenameMapping(mapping: Record<string, string>): string {
|
|
403
|
+
return Object.entries(mapping)
|
|
404
|
+
.map(([src, dest]) => `${src}:${dest}`)
|
|
405
|
+
.join(', ');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function configToJson(config: Config): string {
|
|
409
|
+
const output: Record<string, unknown> = {
|
|
410
|
+
sourceProject: config.sourceProject,
|
|
411
|
+
destProject: config.destProject,
|
|
412
|
+
collections: config.collections,
|
|
413
|
+
includeSubcollections: config.includeSubcollections,
|
|
414
|
+
dryRun: config.dryRun,
|
|
415
|
+
batchSize: config.batchSize,
|
|
416
|
+
limit: config.limit,
|
|
417
|
+
where: serializeWhereFilters(config.where),
|
|
418
|
+
exclude: config.exclude,
|
|
419
|
+
merge: config.merge,
|
|
420
|
+
parallel: config.parallel,
|
|
421
|
+
clear: config.clear,
|
|
422
|
+
deleteMissing: config.deleteMissing,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Include optional fields only if set
|
|
426
|
+
if (config.transform) output.transform = config.transform;
|
|
427
|
+
if (Object.keys(config.renameCollection).length > 0) output.renameCollection = config.renameCollection;
|
|
428
|
+
if (config.idPrefix) output.idPrefix = config.idPrefix;
|
|
429
|
+
if (config.idSuffix) output.idSuffix = config.idSuffix;
|
|
430
|
+
if (config.webhook) output.webhook = config.webhook;
|
|
431
|
+
if (config.rateLimit > 0) output.rateLimit = config.rateLimit;
|
|
432
|
+
if (config.skipOversized) output.skipOversized = config.skipOversized;
|
|
433
|
+
if (config.detectConflicts) output.detectConflicts = config.detectConflicts;
|
|
434
|
+
if (config.maxDepth > 0) output.maxDepth = config.maxDepth;
|
|
435
|
+
if (config.verify) output.verify = config.verify;
|
|
436
|
+
if (config.verifyIntegrity) output.verifyIntegrity = config.verifyIntegrity;
|
|
437
|
+
if (config.retries !== 3) output.retries = config.retries;
|
|
438
|
+
|
|
439
|
+
return JSON.stringify(output, null, 4);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function iniLine(key: string, value: string | number | boolean): string {
|
|
443
|
+
return `${key} = ${value}\n`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function iniComment(key: string, value: string | number | boolean): string {
|
|
447
|
+
return `; ${key} = ${value}\n`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function configToIni(config: Config): string {
|
|
451
|
+
let ini = '; fscopy configuration file\n';
|
|
452
|
+
ini += '; Generated by interactive mode\n\n';
|
|
453
|
+
|
|
454
|
+
// [projects]
|
|
455
|
+
ini += '[projects]\n';
|
|
456
|
+
ini += iniLine('source', config.sourceProject ?? '');
|
|
457
|
+
ini += iniLine('dest', config.destProject ?? '');
|
|
458
|
+
ini += '\n';
|
|
459
|
+
|
|
460
|
+
// [transfer]
|
|
461
|
+
ini += '[transfer]\n';
|
|
462
|
+
ini += iniLine('collections', config.collections.join(', '));
|
|
463
|
+
ini += iniLine('includeSubcollections', config.includeSubcollections);
|
|
464
|
+
ini += iniLine('dryRun', config.dryRun);
|
|
465
|
+
ini += iniLine('batchSize', config.batchSize);
|
|
466
|
+
ini += iniLine('limit', config.limit);
|
|
467
|
+
ini += '\n';
|
|
468
|
+
|
|
469
|
+
// [options]
|
|
470
|
+
ini += '[options]\n';
|
|
471
|
+
|
|
472
|
+
if (config.where.length > 0) {
|
|
473
|
+
ini += iniLine('where', serializeWhereFilters(config.where).join(', '));
|
|
474
|
+
} else {
|
|
475
|
+
ini += iniComment('where', 'status == active');
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (config.exclude.length > 0) {
|
|
479
|
+
ini += iniLine('exclude', config.exclude.join(', '));
|
|
480
|
+
} else {
|
|
481
|
+
ini += iniComment('exclude', 'logs, temp/*, cache');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
ini += iniLine('merge', config.merge);
|
|
485
|
+
ini += iniLine('parallel', config.parallel);
|
|
486
|
+
ini += iniLine('clear', config.clear);
|
|
487
|
+
ini += iniLine('deleteMissing', config.deleteMissing);
|
|
488
|
+
|
|
489
|
+
if (config.transform) {
|
|
490
|
+
ini += iniLine('transform', config.transform);
|
|
491
|
+
} else {
|
|
492
|
+
ini += iniComment('transform', './transforms/anonymize.ts');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (Object.keys(config.renameCollection).length > 0) {
|
|
496
|
+
ini += iniLine('renameCollection', serializeRenameMapping(config.renameCollection));
|
|
497
|
+
} else {
|
|
498
|
+
ini += iniComment('renameCollection', 'users:users_backup, orders:orders_2024');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (config.idPrefix) {
|
|
502
|
+
ini += iniLine('idPrefix', config.idPrefix);
|
|
503
|
+
} else {
|
|
504
|
+
ini += iniComment('idPrefix', 'backup_');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (config.idSuffix) {
|
|
508
|
+
ini += iniLine('idSuffix', config.idSuffix);
|
|
509
|
+
} else {
|
|
510
|
+
ini += iniComment('idSuffix', '_v2');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (config.webhook) {
|
|
514
|
+
ini += iniLine('webhook', config.webhook);
|
|
515
|
+
} else {
|
|
516
|
+
ini += iniComment('webhook', 'https://hooks.slack.com/services/...');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (config.retries !== 3) {
|
|
520
|
+
ini += iniLine('retries', config.retries);
|
|
521
|
+
} else {
|
|
522
|
+
ini += iniComment('retries', 3);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (config.rateLimit > 0) {
|
|
526
|
+
ini += iniLine('rateLimit', config.rateLimit);
|
|
527
|
+
} else {
|
|
528
|
+
ini += iniComment('rateLimit', 0);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
ini += iniLine('skipOversized', config.skipOversized);
|
|
532
|
+
ini += iniLine('detectConflicts', config.detectConflicts);
|
|
533
|
+
|
|
534
|
+
if (config.maxDepth > 0) {
|
|
535
|
+
ini += iniLine('maxDepth', config.maxDepth);
|
|
536
|
+
} else {
|
|
537
|
+
ini += iniComment('maxDepth', 0);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
ini += iniLine('verify', config.verify);
|
|
541
|
+
ini += iniLine('verifyIntegrity', config.verifyIntegrity);
|
|
542
|
+
|
|
543
|
+
return ini;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function saveConfig(config: Config, format: 'ini' | 'json'): Promise<string> {
|
|
547
|
+
const defaultName = format === 'json' ? 'fscopy-config.json' : 'fscopy-config.ini';
|
|
548
|
+
|
|
549
|
+
const filePath = await input({
|
|
550
|
+
message: `Save path:`,
|
|
551
|
+
default: defaultName,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const resolvedPath = path.resolve(filePath);
|
|
555
|
+
|
|
556
|
+
if (fs.existsSync(resolvedPath)) {
|
|
557
|
+
const overwrite = await confirm({
|
|
558
|
+
message: `File "${filePath}" already exists. Overwrite?`,
|
|
559
|
+
default: false,
|
|
560
|
+
});
|
|
561
|
+
if (!overwrite) {
|
|
562
|
+
console.log('\nSave cancelled.\n');
|
|
563
|
+
process.exit(0);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const content = format === 'json' ? configToJson(config) : configToIni(config);
|
|
568
|
+
fs.writeFileSync(resolvedPath, content, 'utf-8');
|
|
569
|
+
|
|
570
|
+
console.log(`\nConfig saved: ${resolvedPath}`);
|
|
571
|
+
console.log(`\n Run with: fscopy -f ${filePath}\n`);
|
|
572
|
+
|
|
573
|
+
return resolvedPath;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// =============================================================================
|
|
577
|
+
// Main interactive flow
|
|
578
|
+
// =============================================================================
|
|
579
|
+
|
|
580
|
+
export async function runInteractiveMode(config: Config): Promise<InteractiveResult> {
|
|
581
|
+
console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
|
|
582
|
+
console.log('FSCOPY - INTERACTIVE MODE');
|
|
583
|
+
console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
|
|
584
|
+
|
|
585
|
+
// 1. Projects
|
|
586
|
+
const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '>>');
|
|
587
|
+
const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '>>');
|
|
588
|
+
|
|
589
|
+
let idPrefix = config.idPrefix;
|
|
590
|
+
let idSuffix = config.idSuffix;
|
|
591
|
+
|
|
592
|
+
if (sourceProject === destProject) {
|
|
593
|
+
const mods = await promptForIdModification(idPrefix, idSuffix);
|
|
594
|
+
idPrefix = mods.idPrefix;
|
|
595
|
+
idSuffix = mods.idSuffix;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 2. Discover collections
|
|
599
|
+
const { app: tempSourceApp, collections: collectionInfo } = await discoverCollections(sourceProject);
|
|
600
|
+
|
|
601
|
+
// 3. Select collections
|
|
133
602
|
console.log('');
|
|
134
603
|
const selectedCollections = await checkbox({
|
|
135
604
|
message: 'Select collections to transfer:',
|
|
@@ -141,7 +610,7 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
141
610
|
validate: (value) => value.length > 0 || 'Select at least one collection',
|
|
142
611
|
});
|
|
143
612
|
|
|
144
|
-
//
|
|
613
|
+
// 4. Basic options
|
|
145
614
|
console.log('');
|
|
146
615
|
const includeSubcollections = await confirm({
|
|
147
616
|
message: 'Include subcollections?',
|
|
@@ -158,11 +627,8 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
158
627
|
default: config.merge,
|
|
159
628
|
});
|
|
160
629
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Return updated config
|
|
165
|
-
return {
|
|
630
|
+
// Build config so far
|
|
631
|
+
let finalConfig: Config = {
|
|
166
632
|
...config,
|
|
167
633
|
sourceProject,
|
|
168
634
|
destProject,
|
|
@@ -173,4 +639,23 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
173
639
|
idPrefix,
|
|
174
640
|
idSuffix,
|
|
175
641
|
};
|
|
642
|
+
|
|
643
|
+
// 5. Advanced options
|
|
644
|
+
console.log('');
|
|
645
|
+
const advancedUpdates = await promptAdvancedOptions(finalConfig);
|
|
646
|
+
finalConfig = { ...finalConfig, ...advancedUpdates };
|
|
647
|
+
|
|
648
|
+
// Clean up temporary Firebase app
|
|
649
|
+
await tempSourceApp.delete();
|
|
650
|
+
|
|
651
|
+
// 6. Final action
|
|
652
|
+
const action = await promptFinalAction();
|
|
653
|
+
|
|
654
|
+
if (action === 'save-ini' || action === 'save-json') {
|
|
655
|
+
const format = action === 'save-json' ? 'json' : 'ini';
|
|
656
|
+
await saveConfig(finalConfig, format);
|
|
657
|
+
return { config: finalConfig, action: 'save' };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return { config: finalConfig, action: 'execute' };
|
|
176
661
|
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -265,6 +265,24 @@ async function validateTransformWithSamples(
|
|
|
265
265
|
output.blank();
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
function canWriteProgress(output: Output): boolean {
|
|
269
|
+
return !output.isQuiet && !output.isJson && process.stdout.isTTY === true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function clearLine(): void {
|
|
273
|
+
if (!process.stdout.isTTY) return;
|
|
274
|
+
const width = typeof process.stdout.columns === 'number' && process.stdout.columns > 0
|
|
275
|
+
? process.stdout.columns
|
|
276
|
+
: SEPARATOR_LENGTH;
|
|
277
|
+
process.stdout.write('\r' + ' '.repeat(width) + '\r');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function formatNameList(names: Set<string>, max: number = 8): string {
|
|
281
|
+
const arr = [...names];
|
|
282
|
+
if (arr.length <= max) return arr.join(', ');
|
|
283
|
+
return arr.slice(0, max).join(', ') + `, ... (+${arr.length - max})`;
|
|
284
|
+
}
|
|
285
|
+
|
|
268
286
|
async function setupProgressTracking(
|
|
269
287
|
sourceDb: Firestore,
|
|
270
288
|
config: Config,
|
|
@@ -276,34 +294,66 @@ async function setupProgressTracking(
|
|
|
276
294
|
|
|
277
295
|
if (!output.isQuiet) {
|
|
278
296
|
output.info('📊 Counting documents...');
|
|
279
|
-
let lastSubcollectionLog = Date.now();
|
|
280
|
-
let subcollectionCount = 0;
|
|
281
|
-
|
|
282
|
-
const countProgress: CountProgress = {
|
|
283
|
-
onCollection: (path, count) => {
|
|
284
|
-
output.info(` ${path}: ${count} documents`);
|
|
285
|
-
},
|
|
286
|
-
onSubcollection: (_path) => {
|
|
287
|
-
subcollectionCount++;
|
|
288
|
-
const now = Date.now();
|
|
289
|
-
if (now - lastSubcollectionLog > PROGRESS_LOG_INTERVAL_MS) {
|
|
290
|
-
process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
|
|
291
|
-
lastSubcollectionLog = now;
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
};
|
|
295
297
|
|
|
296
298
|
for (const collection of config.collections) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
let rootCount = 0;
|
|
300
|
+
let subcollectionInstances = 0;
|
|
301
|
+
const subcollectionNames = new Set<string>();
|
|
302
|
+
const excludedNames = new Set<string>();
|
|
303
|
+
let lastLog = Date.now();
|
|
304
|
+
let showedScanLine = false;
|
|
305
|
+
|
|
306
|
+
if (canWriteProgress(output)) {
|
|
307
|
+
process.stdout.write(` Counting ${collection}...`);
|
|
308
|
+
showedScanLine = true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const countProgress: CountProgress = {
|
|
312
|
+
onCollection: (_path, count) => {
|
|
313
|
+
rootCount = count;
|
|
314
|
+
},
|
|
315
|
+
onSubcollection: (path) => {
|
|
316
|
+
subcollectionInstances++;
|
|
317
|
+
const segments = path.split('/');
|
|
318
|
+
subcollectionNames.add(segments[segments.length - 1]);
|
|
319
|
+
|
|
320
|
+
if (!canWriteProgress(output)) return;
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
if (now - lastLog > PROGRESS_LOG_INTERVAL_MS) {
|
|
323
|
+
process.stdout.write(`\r Scanning ${collection}... (${subcollectionInstances} subcollections found)`);
|
|
324
|
+
showedScanLine = true;
|
|
325
|
+
lastLog = now;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
onSubcollectionExcluded: (name) => {
|
|
329
|
+
excludedNames.add(name);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const collectionTotal = await countDocuments(sourceDb, collection, config, 0, countProgress);
|
|
334
|
+
totalDocs += collectionTotal;
|
|
335
|
+
|
|
336
|
+
// Clear live indicator line
|
|
337
|
+
if (showedScanLine && canWriteProgress(output)) {
|
|
338
|
+
clearLine();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Print collection summary
|
|
342
|
+
output.info(` ${collection}: ${rootCount} documents`);
|
|
299
343
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
344
|
+
if (subcollectionInstances > 0) {
|
|
345
|
+
const subDocs = collectionTotal - rootCount;
|
|
346
|
+
output.info(` + ${subDocs} in subcollections (${formatNameList(subcollectionNames)})`);
|
|
347
|
+
}
|
|
348
|
+
if (excludedNames.size > 0) {
|
|
349
|
+
output.info(` Excluded: ${formatNameList(excludedNames)}`);
|
|
350
|
+
}
|
|
303
351
|
}
|
|
304
|
-
output.info(` Total: ${totalDocs} documents to transfer\n`);
|
|
305
352
|
|
|
306
|
-
|
|
353
|
+
output.info(`\n Total: ${totalDocs} documents to transfer\n`);
|
|
354
|
+
if (canWriteProgress(output)) {
|
|
355
|
+
progressBar.start(totalDocs, stats);
|
|
356
|
+
}
|
|
307
357
|
}
|
|
308
358
|
|
|
309
359
|
return { totalDocs, progressBar };
|
|
@@ -376,14 +426,17 @@ async function deleteOrphanDocs(
|
|
|
376
426
|
let lastProgressLog = Date.now();
|
|
377
427
|
let subcollectionCount = 0;
|
|
378
428
|
|
|
429
|
+
const showProgress = canWriteProgress(output);
|
|
430
|
+
|
|
379
431
|
const progress: DeleteOrphansProgress = {
|
|
380
432
|
onScanStart: (collection) => {
|
|
381
|
-
process.stdout.write(` Scanning ${collection}...`);
|
|
433
|
+
if (showProgress) process.stdout.write(` Scanning ${collection}...`);
|
|
382
434
|
},
|
|
383
435
|
onScanComplete: (collection, orphanCount, totalDest) => {
|
|
384
|
-
process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
|
|
436
|
+
if (showProgress) process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
|
|
385
437
|
},
|
|
386
438
|
onBatchDeleted: (collection, deletedSoFar, total) => {
|
|
439
|
+
if (!showProgress) return;
|
|
387
440
|
process.stdout.write(`\r Deleting from ${collection}... ${deletedSoFar}/${total}`);
|
|
388
441
|
if (deletedSoFar === total) {
|
|
389
442
|
process.stdout.write('\n');
|
|
@@ -391,6 +444,7 @@ async function deleteOrphanDocs(
|
|
|
391
444
|
},
|
|
392
445
|
onSubcollectionScan: (_path) => {
|
|
393
446
|
subcollectionCount++;
|
|
447
|
+
if (!showProgress) return;
|
|
394
448
|
const now = Date.now();
|
|
395
449
|
if (now - lastProgressLog > PROGRESS_LOG_INTERVAL_MS) {
|
|
396
450
|
process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} checked)`);
|
|
@@ -411,8 +465,8 @@ async function deleteOrphanDocs(
|
|
|
411
465
|
stats.documentsDeleted += deleted;
|
|
412
466
|
}
|
|
413
467
|
|
|
414
|
-
if (subcollectionCount > 0) {
|
|
415
|
-
|
|
468
|
+
if (subcollectionCount > 0 && showProgress) {
|
|
469
|
+
clearLine();
|
|
416
470
|
}
|
|
417
471
|
|
|
418
472
|
if (stats.documentsDeleted > 0) {
|
package/src/transfer/count.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getSubcollections } from './helpers.js';
|
|
|
6
6
|
export interface CountProgress {
|
|
7
7
|
onCollection?: (path: string, count: number) => void;
|
|
8
8
|
onSubcollection?: (path: string) => void;
|
|
9
|
+
onSubcollectionExcluded?: (name: string) => void;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
function buildQueryWithFilters(
|
|
@@ -65,11 +66,19 @@ async function countSubcollectionsForDoc(
|
|
|
65
66
|
depth: number,
|
|
66
67
|
progress?: CountProgress
|
|
67
68
|
): Promise<number> {
|
|
69
|
+
// Respect maxDepth to match transfer behavior
|
|
70
|
+
if (config.maxDepth > 0 && depth >= config.maxDepth) return 0;
|
|
71
|
+
|
|
68
72
|
let count = 0;
|
|
69
73
|
const subcollections = await getSubcollections(doc.ref);
|
|
70
74
|
|
|
71
75
|
for (const subId of subcollections) {
|
|
72
|
-
if (matchesExcludePattern(subId, config.exclude))
|
|
76
|
+
if (matchesExcludePattern(subId, config.exclude)) {
|
|
77
|
+
if (progress?.onSubcollectionExcluded) {
|
|
78
|
+
progress.onSubcollectionExcluded(subId);
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
73
82
|
|
|
74
83
|
const subPath = `${collectionPath}/${doc.id}/${subId}`;
|
|
75
84
|
if (progress?.onSubcollection) {
|
package/src/transfer/transfer.ts
CHANGED
|
@@ -101,7 +101,7 @@ async function checkForConflicts(
|
|
|
101
101
|
return conflicts;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function
|
|
104
|
+
function buildBaseQuery(
|
|
105
105
|
sourceDb: Firestore,
|
|
106
106
|
collectionPath: string,
|
|
107
107
|
config: Config,
|
|
@@ -115,10 +115,7 @@ function buildTransferQuery(
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
query = query.limit(config.limit);
|
|
120
|
-
}
|
|
121
|
-
|
|
118
|
+
// Limit is handled via pagination in transferCollection
|
|
122
119
|
return query;
|
|
123
120
|
}
|
|
124
121
|
|
|
@@ -533,25 +530,50 @@ export async function transferCollection(
|
|
|
533
530
|
const { sourceDb, config, stats, output } = ctx;
|
|
534
531
|
const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
|
|
535
532
|
|
|
536
|
-
const
|
|
533
|
+
const baseQuery = buildBaseQuery(sourceDb, collectionPath, config, depth);
|
|
534
|
+
const userLimit = config.limit > 0 && depth === 0 ? config.limit : 0;
|
|
537
535
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
536
|
+
let totalProcessed = 0;
|
|
537
|
+
let lastDoc: QueryDocumentSnapshot | undefined;
|
|
538
|
+
|
|
539
|
+
while (true) {
|
|
540
|
+
// Calculate page size respecting user limit
|
|
541
|
+
let pageSize = config.batchSize;
|
|
542
|
+
if (userLimit > 0) {
|
|
543
|
+
const remaining = userLimit - totalProcessed;
|
|
544
|
+
if (remaining <= 0) break;
|
|
545
|
+
pageSize = Math.min(pageSize, remaining);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Build paginated query
|
|
549
|
+
let pageQuery = baseQuery.limit(pageSize);
|
|
550
|
+
if (lastDoc) {
|
|
551
|
+
pageQuery = pageQuery.startAfter(lastDoc);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const snapshot = await withRetry(() => pageQuery.get(), {
|
|
555
|
+
retries: config.retries,
|
|
556
|
+
onRetry: (attempt, max, err, delay) => {
|
|
557
|
+
output.logError(`Retry ${attempt}/${max} for ${collectionPath}`, {
|
|
558
|
+
error: err.message,
|
|
559
|
+
delay,
|
|
560
|
+
});
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (snapshot.empty) break;
|
|
565
|
+
|
|
566
|
+
if (totalProcessed === 0) {
|
|
567
|
+
stats.collectionsProcessed++;
|
|
568
|
+
output.logInfo(`Processing collection: ${collectionPath}`);
|
|
569
|
+
}
|
|
547
570
|
|
|
548
|
-
|
|
571
|
+
await processBatch(snapshot.docs, ctx, collectionPath, destCollectionPath, depth);
|
|
549
572
|
|
|
550
|
-
|
|
551
|
-
|
|
573
|
+
totalProcessed += snapshot.docs.length;
|
|
574
|
+
lastDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
552
575
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
await processBatch(batch, ctx, collectionPath, destCollectionPath, depth);
|
|
576
|
+
// Fewer docs than requested means we've reached the end
|
|
577
|
+
if (snapshot.docs.length < pageSize) break;
|
|
556
578
|
}
|
|
557
579
|
}
|