@fazetitans/fscopy 1.3.1 → 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 -8
- 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',
|
|
@@ -166,17 +165,14 @@ const argv = yargs(hideBin(process.argv))
|
|
|
166
165
|
.option('verify', {
|
|
167
166
|
type: 'boolean',
|
|
168
167
|
description: 'Verify document counts after transfer',
|
|
169
|
-
default: false,
|
|
170
168
|
})
|
|
171
169
|
.option('rate-limit', {
|
|
172
170
|
type: 'number',
|
|
173
171
|
description: 'Limit transfer rate (documents per second, 0 = unlimited)',
|
|
174
|
-
default: 0,
|
|
175
172
|
})
|
|
176
173
|
.option('skip-oversized', {
|
|
177
174
|
type: 'boolean',
|
|
178
175
|
description: 'Skip documents exceeding 1MB instead of failing',
|
|
179
|
-
default: false,
|
|
180
176
|
})
|
|
181
177
|
.option('json', {
|
|
182
178
|
type: 'boolean',
|
|
@@ -191,17 +187,14 @@ const argv = yargs(hideBin(process.argv))
|
|
|
191
187
|
.option('detect-conflicts', {
|
|
192
188
|
type: 'boolean',
|
|
193
189
|
description: 'Detect if destination docs were modified during transfer',
|
|
194
|
-
default: false,
|
|
195
190
|
})
|
|
196
191
|
.option('max-depth', {
|
|
197
192
|
type: 'number',
|
|
198
193
|
description: 'Max subcollection depth (0 = unlimited)',
|
|
199
|
-
default: 0,
|
|
200
194
|
})
|
|
201
195
|
.option('verify-integrity', {
|
|
202
196
|
type: 'boolean',
|
|
203
197
|
description: 'Verify document integrity with hash after transfer',
|
|
204
|
-
default: false,
|
|
205
198
|
})
|
|
206
199
|
.option('validate-only', {
|
|
207
200
|
type: 'boolean',
|
|
@@ -250,7 +243,11 @@ async function main(): Promise<void> {
|
|
|
250
243
|
|
|
251
244
|
// Run interactive mode if enabled
|
|
252
245
|
if (argv.interactive) {
|
|
253
|
-
|
|
246
|
+
const result = await runInteractiveMode(config);
|
|
247
|
+
config = result.config;
|
|
248
|
+
if (result.action === 'save') {
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
254
251
|
}
|
|
255
252
|
|
|
256
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
|
}
|