@fazetitans/fscopy 1.4.0 → 1.5.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/README.md +12 -1
- package/package.json +19 -15
- package/src/cli.ts +10 -2
- package/src/config/defaults.ts +1 -0
- package/src/config/parser.ts +90 -53
- package/src/config/validator.ts +51 -0
- package/src/constants.ts +39 -0
- package/src/firebase/index.ts +29 -18
- package/src/interactive.ts +6 -1
- package/src/orchestrator.ts +69 -57
- package/src/state/index.ts +42 -26
- package/src/transfer/clear.ts +104 -68
- package/src/transfer/count.ts +44 -35
- package/src/transfer/helpers.ts +36 -1
- package/src/transfer/index.ts +7 -1
- package/src/transfer/transfer.ts +141 -149
- package/src/transform/loader.ts +13 -1
- package/src/types.ts +3 -1
- package/src/utils/credentials.ts +7 -4
- package/src/utils/errors.ts +7 -2
- package/src/utils/index.ts +6 -1
- package/src/utils/output.ts +6 -0
- package/src/utils/progress.ts +10 -0
- package/src/webhook/index.ts +127 -44
package/src/orchestrator.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { RateLimiter } from './utils/rate-limiter.js';
|
|
|
7
7
|
import { ProgressBarWrapper } from './utils/progress.js';
|
|
8
8
|
import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState, StateSaver } from './state/index.js';
|
|
9
9
|
import { sendWebhook } from './webhook/index.js';
|
|
10
|
-
import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress, type DeleteOrphansProgress } from './transfer/index.js';
|
|
10
|
+
import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, buildQueryWithFilters, type TransferContext, type CountProgress, type DeleteOrphansProgress } from './transfer/index.js';
|
|
11
11
|
import { initializeFirebase, checkDatabaseConnectivity, cleanupFirebase } from './firebase/index.js';
|
|
12
12
|
import { loadTransformFunction } from './transform/loader.js';
|
|
13
13
|
import { printSummary, formatJsonOutput } from './output/display.js';
|
|
@@ -32,10 +32,13 @@ function initializeResumeMode(config: ValidatedConfig, output: Output): ResumeRe
|
|
|
32
32
|
throw new Error(`No state file found at ${config.stateFile}. Cannot resume without a saved state. Run without --resume to start fresh.`);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const stateErrors = validateStateForResume(existingState, config);
|
|
35
|
+
const { errors: stateErrors, warnings: stateWarnings } = validateStateForResume(existingState, config);
|
|
36
36
|
if (stateErrors.length > 0) {
|
|
37
37
|
throw new Error(`Cannot resume: state file incompatible with current config:\n - ${stateErrors.join('\n - ')}`);
|
|
38
38
|
}
|
|
39
|
+
for (const warning of stateWarnings) {
|
|
40
|
+
output.warn(`⚠️ ${warning}`);
|
|
41
|
+
}
|
|
39
42
|
|
|
40
43
|
const completedCount = Object.values(existingState.completedDocs).reduce((sum, ids) => sum + ids.length, 0);
|
|
41
44
|
output.info(`\n🔄 Resuming transfer from ${config.stateFile}`);
|
|
@@ -148,6 +151,31 @@ function displayTransferOptions(config: Config, rateLimiter: RateLimiter | null,
|
|
|
148
151
|
export async function runTransfer(config: ValidatedConfig, argv: CliArgs, output: Output): Promise<TransferResult> {
|
|
149
152
|
const startTime = Date.now();
|
|
150
153
|
|
|
154
|
+
// Graceful shutdown state
|
|
155
|
+
let stateSaverRef: StateSaver | null = null;
|
|
156
|
+
let progressBarRef: ProgressBarWrapper | null = null;
|
|
157
|
+
let interrupted = false;
|
|
158
|
+
|
|
159
|
+
const onSignal = () => {
|
|
160
|
+
if (interrupted) {
|
|
161
|
+
// Second signal: force exit
|
|
162
|
+
process.exit(130);
|
|
163
|
+
}
|
|
164
|
+
interrupted = true;
|
|
165
|
+
|
|
166
|
+
// Flush state and stop progress bar synchronously
|
|
167
|
+
stateSaverRef?.flush();
|
|
168
|
+
progressBarRef?.stop();
|
|
169
|
+
|
|
170
|
+
output.print('\n\n⚠️ Transfer interrupted. State saved for resume (use --resume).');
|
|
171
|
+
process.exit(130);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
process.on('SIGINT', onSignal);
|
|
175
|
+
process.on('SIGTERM', onSignal);
|
|
176
|
+
|
|
177
|
+
let currentStats: Stats = createEmptyStats();
|
|
178
|
+
|
|
151
179
|
try {
|
|
152
180
|
const { state: transferState, stats } = initializeResumeMode(config, output);
|
|
153
181
|
const transformFn = await loadTransform(config, output);
|
|
@@ -156,26 +184,29 @@ export async function runTransfer(config: ValidatedConfig, argv: CliArgs, output
|
|
|
156
184
|
const { sourceDb, destDb } = initializeFirebase(config);
|
|
157
185
|
await checkDatabaseConnectivity(sourceDb, destDb, config, output);
|
|
158
186
|
|
|
159
|
-
if (transformFn && config.
|
|
187
|
+
if (transformFn && config.transformSamples !== 0) {
|
|
160
188
|
await validateTransformWithSamples(sourceDb, config, transformFn, output);
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
|
|
191
|
+
currentStats = config.resume ? stats : createEmptyStats();
|
|
164
192
|
|
|
165
193
|
if (config.clear) {
|
|
166
194
|
await clearDestinationCollections(destDb, config, currentStats, output);
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
const { progressBar } = await setupProgressTracking(sourceDb, config, currentStats, output);
|
|
198
|
+
progressBarRef = progressBar;
|
|
170
199
|
|
|
171
200
|
const rateLimiter = config.rateLimit > 0 ? new RateLimiter(config.rateLimit) : null;
|
|
172
201
|
displayTransferOptions(config, rateLimiter, output);
|
|
173
202
|
|
|
174
203
|
const stateSaver = transferState ? new StateSaver(config.stateFile, transferState) : null;
|
|
204
|
+
stateSaverRef = stateSaver;
|
|
175
205
|
|
|
176
206
|
const conflictList: ConflictInfo[] = [];
|
|
177
207
|
const ctx: TransferContext = {
|
|
178
|
-
sourceDb, destDb, config, stats: currentStats, output, progressBar, transformFn,
|
|
208
|
+
sourceDb, destDb, config, stats: currentStats, output, progressBar, transformFn,
|
|
209
|
+
stateSaver, rateLimiter, conflictList, maxDepthWarningsShown: new Set<string>(),
|
|
179
210
|
};
|
|
180
211
|
|
|
181
212
|
await executeTransfer(ctx, output);
|
|
@@ -206,10 +237,13 @@ export async function runTransfer(config: ValidatedConfig, argv: CliArgs, output
|
|
|
206
237
|
const errorMessage = (error as Error).message;
|
|
207
238
|
const duration = (Date.now() - startTime) / 1000;
|
|
208
239
|
|
|
209
|
-
await handleErrorOutput(config,
|
|
240
|
+
await handleErrorOutput(config, currentStats, duration, errorMessage, output);
|
|
210
241
|
await cleanupFirebase();
|
|
211
242
|
|
|
212
|
-
return { success: false, stats:
|
|
243
|
+
return { success: false, stats: currentStats, duration, error: errorMessage };
|
|
244
|
+
} finally {
|
|
245
|
+
process.removeListener('SIGINT', onSignal);
|
|
246
|
+
process.removeListener('SIGTERM', onSignal);
|
|
213
247
|
}
|
|
214
248
|
}
|
|
215
249
|
|
|
@@ -295,62 +329,40 @@ async function setupProgressTracking(
|
|
|
295
329
|
if (!output.isQuiet) {
|
|
296
330
|
output.info('📊 Counting documents...');
|
|
297
331
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
332
|
+
if (config.includeSubcollections) {
|
|
333
|
+
// Use .count() aggregation for root collections (1 read per collection)
|
|
334
|
+
// Subcollection docs will be counted lazily during transfer to avoid
|
|
335
|
+
// reading every document twice (once for count, once for transfer)
|
|
336
|
+
for (const collection of config.collections) {
|
|
337
|
+
const query = buildQueryWithFilters(sourceDb, collection, config, 0);
|
|
338
|
+
const countSnap = await query.count().get();
|
|
339
|
+
let rootCount = countSnap.data().count;
|
|
340
|
+
|
|
341
|
+
if (config.limit > 0) {
|
|
342
|
+
rootCount = Math.min(rootCount, config.limit);
|
|
343
|
+
}
|
|
310
344
|
|
|
311
|
-
|
|
312
|
-
|
|
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();
|
|
345
|
+
totalDocs += rootCount;
|
|
346
|
+
output.info(` ${collection}: ${rootCount} documents (+ subcollections)`);
|
|
339
347
|
}
|
|
340
348
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
349
|
+
output.info(`\n Total: ${totalDocs} root documents (subcollections counted during transfer)\n`);
|
|
350
|
+
} else {
|
|
351
|
+
// Without subcollections, use the efficient countDocuments (already uses .count())
|
|
352
|
+
for (const collection of config.collections) {
|
|
353
|
+
const countProgress: CountProgress = {
|
|
354
|
+
onCollection: (_path, count) => {
|
|
355
|
+
output.info(` ${collection}: ${count} documents`);
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const collectionTotal = await countDocuments(sourceDb, collection, config, 0, countProgress);
|
|
360
|
+
totalDocs += collectionTotal;
|
|
350
361
|
}
|
|
362
|
+
|
|
363
|
+
output.info(`\n Total: ${totalDocs} documents to transfer\n`);
|
|
351
364
|
}
|
|
352
365
|
|
|
353
|
-
output.info(`\n Total: ${totalDocs} documents to transfer\n`);
|
|
354
366
|
if (canWriteProgress(output)) {
|
|
355
367
|
progressBar.start(totalDocs, stats);
|
|
356
368
|
}
|
package/src/state/index.ts
CHANGED
|
@@ -2,6 +2,10 @@ import fs from 'node:fs';
|
|
|
2
2
|
import { STATE_SAVE_INTERVAL_MS, STATE_SAVE_BATCH_INTERVAL } from '../constants.js';
|
|
3
3
|
import type { Config, ValidatedConfig, TransferState, Stats } from '../types.js';
|
|
4
4
|
|
|
5
|
+
function stderrWarn(message: string): void {
|
|
6
|
+
process.stderr.write(`${message}\n`);
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
export const STATE_VERSION = 1;
|
|
6
10
|
|
|
7
11
|
// =============================================================================
|
|
@@ -192,18 +196,33 @@ export function loadTransferState(stateFile: string): TransferState | null {
|
|
|
192
196
|
return null;
|
|
193
197
|
}
|
|
194
198
|
const content = fs.readFileSync(stateFile, 'utf-8');
|
|
195
|
-
|
|
199
|
+
|
|
200
|
+
let state: TransferState;
|
|
201
|
+
try {
|
|
202
|
+
state = JSON.parse(content) as TransferState;
|
|
203
|
+
} catch {
|
|
204
|
+
stderrWarn(`⚠️ State file is corrupted (invalid JSON): ${stateFile}`);
|
|
205
|
+
stderrWarn(' Delete the file and restart, or run without --resume.');
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!state.version) {
|
|
210
|
+
stderrWarn(`⚠️ State file is missing version field: ${stateFile}`);
|
|
211
|
+
stderrWarn(' The file may be corrupted. Delete it and restart.');
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
196
214
|
|
|
197
215
|
if (state.version !== STATE_VERSION) {
|
|
198
|
-
|
|
216
|
+
stderrWarn(
|
|
199
217
|
`⚠️ State file version mismatch (expected ${STATE_VERSION}, got ${state.version})`
|
|
200
218
|
);
|
|
219
|
+
stderrWarn(' Delete the file and restart to use the current format.');
|
|
201
220
|
return null;
|
|
202
221
|
}
|
|
203
222
|
|
|
204
223
|
return state;
|
|
205
224
|
} catch (error) {
|
|
206
|
-
|
|
225
|
+
stderrWarn(`⚠️ Failed to read state file: ${(error as Error).message}`);
|
|
207
226
|
return null;
|
|
208
227
|
}
|
|
209
228
|
}
|
|
@@ -228,7 +247,7 @@ export function saveTransferState(stateFile: string, state: TransferState): void
|
|
|
228
247
|
// Ignore cleanup errors
|
|
229
248
|
}
|
|
230
249
|
// Log but don't throw - state save failure shouldn't stop the transfer
|
|
231
|
-
|
|
250
|
+
stderrWarn(`⚠️ Failed to save state file: ${(error as Error).message}`);
|
|
232
251
|
}
|
|
233
252
|
}
|
|
234
253
|
|
|
@@ -262,8 +281,14 @@ export function createInitialState(config: ValidatedConfig): TransferState {
|
|
|
262
281
|
};
|
|
263
282
|
}
|
|
264
283
|
|
|
265
|
-
export
|
|
284
|
+
export interface ResumeValidation {
|
|
285
|
+
errors: string[];
|
|
286
|
+
warnings: string[];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function validateStateForResume(state: TransferState, config: Config): ResumeValidation {
|
|
266
290
|
const errors: string[] = [];
|
|
291
|
+
const warnings: string[] = [];
|
|
267
292
|
|
|
268
293
|
if (state.sourceProject !== config.sourceProject) {
|
|
269
294
|
errors.push(
|
|
@@ -276,7 +301,7 @@ export function validateStateForResume(state: TransferState, config: Config): st
|
|
|
276
301
|
);
|
|
277
302
|
}
|
|
278
303
|
|
|
279
|
-
// Check if
|
|
304
|
+
// Check if state collections are still in config
|
|
280
305
|
const configCollections = new Set(config.collections);
|
|
281
306
|
for (const col of state.collections) {
|
|
282
307
|
if (!configCollections.has(col)) {
|
|
@@ -284,25 +309,16 @@ export function validateStateForResume(state: TransferState, config: Config): st
|
|
|
284
309
|
}
|
|
285
310
|
}
|
|
286
311
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
)
|
|
295
|
-
|
|
296
|
-
return completedInCollection ? completedInCollection.includes(docId) : false;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export function markDocCompleted(
|
|
300
|
-
state: TransferState,
|
|
301
|
-
collectionPath: string,
|
|
302
|
-
docId: string
|
|
303
|
-
): void {
|
|
304
|
-
if (!state.completedDocs[collectionPath]) {
|
|
305
|
-
state.completedDocs[collectionPath] = [];
|
|
312
|
+
// Check for orphaned completedDocs entries (subcollections or removed collections)
|
|
313
|
+
const stateCollections = new Set(state.collections);
|
|
314
|
+
for (const collectionPath of Object.keys(state.completedDocs)) {
|
|
315
|
+
const rootCollection = collectionPath.split('/')[0];
|
|
316
|
+
if (!configCollections.has(rootCollection) && !stateCollections.has(rootCollection)) {
|
|
317
|
+
warnings.push(
|
|
318
|
+
`State has completed docs for "${collectionPath}" which is no longer in config (will be ignored)`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
306
321
|
}
|
|
307
|
-
|
|
322
|
+
|
|
323
|
+
return { errors, warnings };
|
|
308
324
|
}
|
package/src/transfer/clear.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { Firestore, QueryDocumentSnapshot } from 'firebase-admin/firestore'
|
|
|
2
2
|
import type { Config } from '../types.js';
|
|
3
3
|
import type { Output } from '../utils/output.js';
|
|
4
4
|
import { withRetry } from '../utils/retry.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { getFilteredSubcollections, getDestCollectionPath } from './helpers.js';
|
|
6
|
+
import { CLEAR_PAGE_SIZE } from '../constants.js';
|
|
7
7
|
|
|
8
8
|
async function clearDocSubcollections(
|
|
9
9
|
db: Firestore,
|
|
@@ -13,11 +13,9 @@ async function clearDocSubcollections(
|
|
|
13
13
|
output: Output
|
|
14
14
|
): Promise<number> {
|
|
15
15
|
let deletedCount = 0;
|
|
16
|
-
const subcollections = await
|
|
16
|
+
const subcollections = await getFilteredSubcollections(doc.ref, config.exclude);
|
|
17
17
|
|
|
18
18
|
for (const subId of subcollections) {
|
|
19
|
-
if (matchesExcludePattern(subId, config.exclude)) continue;
|
|
20
|
-
|
|
21
19
|
const subPath = `${collectionPath}/${doc.id}/${subId}`;
|
|
22
20
|
deletedCount += await clearCollection(db, subPath, config, output, true);
|
|
23
21
|
}
|
|
@@ -61,42 +59,45 @@ export async function clearCollection(
|
|
|
61
59
|
output: Output,
|
|
62
60
|
includeSubcollections: boolean
|
|
63
61
|
): Promise<number> {
|
|
64
|
-
const snapshot = await db.collection(collectionPath).get();
|
|
65
|
-
if (snapshot.empty) return 0;
|
|
66
|
-
|
|
67
62
|
let deletedCount = 0;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
let lastDoc: QueryDocumentSnapshot | undefined;
|
|
64
|
+
const pageSize = Math.min(config.batchSize, CLEAR_PAGE_SIZE);
|
|
65
|
+
|
|
66
|
+
// Paginate through the collection to avoid loading all docs into memory
|
|
67
|
+
while (true) {
|
|
68
|
+
let query = db.collection(collectionPath).select().limit(pageSize);
|
|
69
|
+
if (lastDoc) {
|
|
70
|
+
query = query.startAfter(lastDoc);
|
|
73
71
|
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Delete documents in batches
|
|
77
|
-
for (let i = 0; i < snapshot.docs.length; i += config.batchSize) {
|
|
78
|
-
const batch = snapshot.docs.slice(i, i + config.batchSize);
|
|
79
|
-
deletedCount += await deleteBatch(db, batch, collectionPath, config, output);
|
|
80
|
-
}
|
|
81
72
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
73
|
+
const snapshot = await query.get();
|
|
74
|
+
if (snapshot.empty) break;
|
|
75
|
+
|
|
76
|
+
// Delete subcollections first if enabled
|
|
77
|
+
if (includeSubcollections) {
|
|
78
|
+
for (const doc of snapshot.docs) {
|
|
79
|
+
deletedCount += await clearDocSubcollections(
|
|
80
|
+
db,
|
|
81
|
+
doc,
|
|
82
|
+
collectionPath,
|
|
83
|
+
config,
|
|
84
|
+
output
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
94
88
|
|
|
95
|
-
|
|
96
|
-
|
|
89
|
+
// Delete documents in batches
|
|
90
|
+
for (let i = 0; i < snapshot.docs.length; i += config.batchSize) {
|
|
91
|
+
const batch = snapshot.docs.slice(i, i + config.batchSize);
|
|
92
|
+
deletedCount += await deleteBatch(db, batch, collectionPath, config, output);
|
|
93
|
+
}
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
// In dry-run mode, we need to paginate using the last doc since docs aren't actually deleted
|
|
96
|
+
if (config.dryRun) {
|
|
97
|
+
lastDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
98
|
+
if (snapshot.docs.length < pageSize) break;
|
|
99
|
+
}
|
|
100
|
+
// In live mode, docs are deleted so we always query from the start (no lastDoc needed)
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
return deletedCount;
|
|
@@ -114,7 +115,7 @@ async function deleteOrphanBatch(
|
|
|
114
115
|
|
|
115
116
|
for (const doc of batch) {
|
|
116
117
|
if (config.includeSubcollections) {
|
|
117
|
-
deletedCount += await
|
|
118
|
+
deletedCount += await clearDocSubcollections(
|
|
118
119
|
destDb,
|
|
119
120
|
doc,
|
|
120
121
|
destCollectionPath,
|
|
@@ -157,10 +158,8 @@ async function processSubcollectionOrphansWithProgress(
|
|
|
157
158
|
let deletedCount = 0;
|
|
158
159
|
|
|
159
160
|
for (const sourceDoc of sourceSnapshot.docs) {
|
|
160
|
-
const sourceSubcollections = await
|
|
161
|
+
const sourceSubcollections = await getFilteredSubcollections(sourceDoc.ref, config.exclude);
|
|
161
162
|
for (const subId of sourceSubcollections) {
|
|
162
|
-
if (matchesExcludePattern(subId, config.exclude)) continue;
|
|
163
|
-
|
|
164
163
|
const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
|
|
165
164
|
progress?.onSubcollectionScan?.(subPath);
|
|
166
165
|
deletedCount += await deleteOrphanDocuments(
|
|
@@ -196,42 +195,79 @@ export async function deleteOrphanDocuments(
|
|
|
196
195
|
|
|
197
196
|
progress?.onScanStart?.(destCollectionPath);
|
|
198
197
|
|
|
199
|
-
|
|
200
|
-
const sourceIds = new Set(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
198
|
+
// Paginate source to build ID set (strings only, minimal memory)
|
|
199
|
+
const sourceIds = new Set<string>();
|
|
200
|
+
let lastSourceDoc: QueryDocumentSnapshot | undefined;
|
|
201
|
+
while (true) {
|
|
202
|
+
let query = sourceDb.collection(sourceCollectionPath).select().limit(CLEAR_PAGE_SIZE);
|
|
203
|
+
if (lastSourceDoc) query = query.startAfter(lastSourceDoc);
|
|
204
|
+
const snapshot = await query.get();
|
|
205
|
+
if (snapshot.empty) break;
|
|
206
|
+
for (const doc of snapshot.docs) sourceIds.add(doc.id);
|
|
207
|
+
lastSourceDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
208
|
+
if (snapshot.size < CLEAR_PAGE_SIZE) break;
|
|
209
|
+
}
|
|
206
210
|
|
|
211
|
+
// Paginate dest and find/delete orphans in batches
|
|
207
212
|
let deletedCount = 0;
|
|
213
|
+
let totalDest = 0;
|
|
214
|
+
let totalOrphans = 0;
|
|
215
|
+
let lastDestDoc: QueryDocumentSnapshot | undefined;
|
|
216
|
+
|
|
217
|
+
while (true) {
|
|
218
|
+
let query = destDb.collection(destCollectionPath).select().limit(CLEAR_PAGE_SIZE);
|
|
219
|
+
if (lastDestDoc) query = query.startAfter(lastDestDoc);
|
|
220
|
+
const snapshot = await query.get();
|
|
221
|
+
if (snapshot.empty) break;
|
|
222
|
+
|
|
223
|
+
totalDest += snapshot.size;
|
|
224
|
+
const orphanDocs = snapshot.docs.filter((doc) => !sourceIds.has(doc.id));
|
|
225
|
+
totalOrphans += orphanDocs.length;
|
|
226
|
+
|
|
227
|
+
if (orphanDocs.length > 0) {
|
|
228
|
+
output.logInfo(`Found ${orphanDocs.length} orphan documents in ${destCollectionPath}`);
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < orphanDocs.length; i += config.batchSize) {
|
|
231
|
+
const batch = orphanDocs.slice(i, i + config.batchSize);
|
|
232
|
+
deletedCount += await deleteOrphanBatch(
|
|
233
|
+
destDb,
|
|
234
|
+
batch,
|
|
235
|
+
destCollectionPath,
|
|
236
|
+
config,
|
|
237
|
+
output
|
|
238
|
+
);
|
|
239
|
+
progress?.onBatchDeleted?.(destCollectionPath, deletedCount, totalOrphans);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
208
242
|
|
|
209
|
-
|
|
210
|
-
|
|
243
|
+
lastDestDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
244
|
+
if (snapshot.size < CLEAR_PAGE_SIZE) break;
|
|
245
|
+
}
|
|
211
246
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
247
|
+
progress?.onScanComplete?.(destCollectionPath, totalOrphans, totalDest);
|
|
248
|
+
|
|
249
|
+
// Process subcollection orphans (paginate source docs)
|
|
250
|
+
if (config.includeSubcollections) {
|
|
251
|
+
let lastSubDoc: QueryDocumentSnapshot | undefined;
|
|
252
|
+
while (true) {
|
|
253
|
+
let query = sourceDb.collection(sourceCollectionPath).select().limit(CLEAR_PAGE_SIZE);
|
|
254
|
+
if (lastSubDoc) query = query.startAfter(lastSubDoc);
|
|
255
|
+
const snapshot = await query.get();
|
|
256
|
+
if (snapshot.empty) break;
|
|
257
|
+
|
|
258
|
+
deletedCount += await processSubcollectionOrphansWithProgress(
|
|
259
|
+
sourceDb,
|
|
215
260
|
destDb,
|
|
216
|
-
|
|
217
|
-
|
|
261
|
+
snapshot,
|
|
262
|
+
sourceCollectionPath,
|
|
218
263
|
config,
|
|
219
|
-
output
|
|
264
|
+
output,
|
|
265
|
+
progress
|
|
220
266
|
);
|
|
221
|
-
progress?.onBatchDeleted?.(destCollectionPath, deletedCount, orphanDocs.length);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
267
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
destDb,
|
|
229
|
-
sourceSnapshot,
|
|
230
|
-
sourceCollectionPath,
|
|
231
|
-
config,
|
|
232
|
-
output,
|
|
233
|
-
progress
|
|
234
|
-
);
|
|
268
|
+
lastSubDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
269
|
+
if (snapshot.size < CLEAR_PAGE_SIZE) break;
|
|
270
|
+
}
|
|
235
271
|
}
|
|
236
272
|
|
|
237
273
|
return deletedCount;
|
package/src/transfer/count.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { Firestore, Query } from 'firebase-admin/firestore';
|
|
1
|
+
import type { Firestore, Query, QueryDocumentSnapshot } from 'firebase-admin/firestore';
|
|
2
2
|
import type { Config } from '../types.js';
|
|
3
3
|
import { matchesExcludePattern } from '../utils/patterns.js';
|
|
4
|
-
import { getSubcollections } from './helpers.js';
|
|
4
|
+
import { getSubcollections, buildQueryWithFilters } from './helpers.js';
|
|
5
|
+
import { CLEAR_PAGE_SIZE } from '../constants.js';
|
|
5
6
|
|
|
6
7
|
export interface CountProgress {
|
|
7
8
|
onCollection?: (path: string, count: number) => void;
|
|
@@ -9,21 +10,6 @@ export interface CountProgress {
|
|
|
9
10
|
onSubcollectionExcluded?: (name: string) => void;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
function buildQueryWithFilters(
|
|
13
|
-
sourceDb: Firestore,
|
|
14
|
-
collectionPath: string,
|
|
15
|
-
config: Config,
|
|
16
|
-
depth: number
|
|
17
|
-
): Query {
|
|
18
|
-
let query: Query = sourceDb.collection(collectionPath);
|
|
19
|
-
if (depth === 0 && config.where.length > 0) {
|
|
20
|
-
for (const filter of config.where) {
|
|
21
|
-
query = query.where(filter.field, filter.operator, filter.value);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return query;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
13
|
async function countWithSubcollections(
|
|
28
14
|
sourceDb: Firestore,
|
|
29
15
|
query: Query,
|
|
@@ -32,30 +18,53 @@ async function countWithSubcollections(
|
|
|
32
18
|
depth: number,
|
|
33
19
|
progress?: CountProgress
|
|
34
20
|
): Promise<number> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
21
|
+
const userLimit = depth === 0 && config.limit > 0 ? config.limit : 0;
|
|
22
|
+
let rootCount = 0;
|
|
23
|
+
let subCount = 0;
|
|
24
|
+
let lastDoc: QueryDocumentSnapshot | undefined;
|
|
25
|
+
|
|
26
|
+
while (true) {
|
|
27
|
+
let pageSize = CLEAR_PAGE_SIZE;
|
|
28
|
+
if (userLimit > 0) {
|
|
29
|
+
const remaining = userLimit - rootCount;
|
|
30
|
+
if (remaining <= 0) break;
|
|
31
|
+
pageSize = Math.min(pageSize, remaining);
|
|
32
|
+
}
|
|
39
33
|
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
let pageQuery = query.select().limit(pageSize);
|
|
35
|
+
if (lastDoc) {
|
|
36
|
+
pageQuery = pageQuery.startAfter(lastDoc);
|
|
37
|
+
}
|
|
42
38
|
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
const snapshot = await pageQuery.get();
|
|
40
|
+
if (snapshot.empty) break;
|
|
41
|
+
|
|
42
|
+
rootCount += snapshot.size;
|
|
43
|
+
|
|
44
|
+
if (depth === 0 && progress?.onCollection) {
|
|
45
|
+
progress.onCollection(collectionPath, rootCount);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const doc of snapshot.docs) {
|
|
49
|
+
subCount += await countSubcollectionsForDoc(
|
|
50
|
+
sourceDb,
|
|
51
|
+
doc,
|
|
52
|
+
collectionPath,
|
|
53
|
+
config,
|
|
54
|
+
depth,
|
|
55
|
+
progress
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
lastDoc = snapshot.docs[snapshot.docs.length - 1];
|
|
60
|
+
if (snapshot.size < pageSize) break;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
sourceDb,
|
|
50
|
-
doc,
|
|
51
|
-
collectionPath,
|
|
52
|
-
config,
|
|
53
|
-
depth,
|
|
54
|
-
progress
|
|
55
|
-
);
|
|
63
|
+
if (rootCount === 0 && depth === 0 && progress?.onCollection) {
|
|
64
|
+
progress.onCollection(collectionPath, 0);
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
return
|
|
67
|
+
return rootCount + subCount;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
async function countSubcollectionsForDoc(
|