@fazetitans/fscopy 1.1.2 → 1.2.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.
@@ -1,12 +1,13 @@
1
- import type { Firestore, WriteBatch } from 'firebase-admin/firestore';
2
- import type cliProgress from 'cli-progress';
3
- import type { Config, Stats, TransferState, TransformFunction } from '../types.js';
4
- import type { Logger } from '../utils/logger.js';
1
+ import type { Firestore, WriteBatch, Query, QueryDocumentSnapshot } from 'firebase-admin/firestore';
2
+ import type { Config, Stats, TransformFunction, ConflictInfo } from '../types.js';
3
+ import type { Output } from '../utils/output.js';
5
4
  import type { RateLimiter } from '../utils/rate-limiter.js';
5
+ import type { ProgressBarWrapper } from '../utils/progress.js';
6
+ import type { StateSaver } from '../state/index.js';
6
7
  import { withRetry } from '../utils/retry.js';
7
8
  import { matchesExcludePattern } from '../utils/patterns.js';
8
9
  import { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from '../utils/doc-size.js';
9
- import { isDocCompleted, markDocCompleted, saveTransferState } from '../state/index.js';
10
+ import { hashDocumentData, compareHashes } from '../utils/integrity.js';
10
11
  import { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
11
12
 
12
13
  export interface TransferContext {
@@ -14,27 +15,100 @@ export interface TransferContext {
14
15
  destDb: Firestore;
15
16
  config: Config;
16
17
  stats: Stats;
17
- logger: Logger;
18
- progressBar: cliProgress.SingleBar | null;
18
+ output: Output;
19
+ progressBar: ProgressBarWrapper;
19
20
  transformFn: TransformFunction | null;
20
- state: TransferState | null;
21
+ stateSaver: StateSaver | null;
21
22
  rateLimiter: RateLimiter | null;
23
+ conflictList: ConflictInfo[];
22
24
  }
23
25
 
24
- export async function transferCollection(
25
- ctx: TransferContext,
26
- collectionPath: string,
27
- depth: number = 0
28
- ): Promise<void> {
29
- const { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state, rateLimiter } = ctx;
26
+ interface DocProcessResult {
27
+ skip: boolean;
28
+ data?: Record<string, unknown>;
29
+ markCompleted: boolean;
30
+ }
30
31
 
31
- // Get the destination path (may be renamed)
32
- const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
32
+ // Map of destDocId -> updateTime (as ISO string for comparison)
33
+ type UpdateTimeMap = Map<string, string | null>;
34
+
35
+ /**
36
+ * Capture updateTime of destination documents before processing.
37
+ * Returns a map of docId -> updateTime (ISO string, or null if doc doesn't exist).
38
+ */
39
+ async function captureDestUpdateTimes(
40
+ destDb: Firestore,
41
+ destCollectionPath: string,
42
+ destDocIds: string[]
43
+ ): Promise<UpdateTimeMap> {
44
+ const updateTimes: UpdateTimeMap = new Map();
45
+
46
+ // Batch get dest docs to get their updateTime
47
+ const docRefs = destDocIds.map((id) => destDb.collection(destCollectionPath).doc(id));
48
+ const docs = await destDb.getAll(...docRefs);
33
49
 
34
- const sourceCollectionRef = sourceDb.collection(collectionPath);
35
- let query: FirebaseFirestore.Query = sourceCollectionRef;
50
+ for (let i = 0; i < docs.length; i++) {
51
+ const doc = docs[i];
52
+ const docId = destDocIds[i];
53
+ if (doc.exists) {
54
+ const updateTime = doc.updateTime;
55
+ updateTimes.set(docId, updateTime ? updateTime.toDate().toISOString() : null);
56
+ } else {
57
+ updateTimes.set(docId, null);
58
+ }
59
+ }
60
+
61
+ return updateTimes;
62
+ }
63
+
64
+ /**
65
+ * Check for conflicts by comparing current updateTimes with captured ones.
66
+ * Returns array of docIds that have conflicts.
67
+ */
68
+ async function checkForConflicts(
69
+ destDb: Firestore,
70
+ destCollectionPath: string,
71
+ destDocIds: string[],
72
+ capturedTimes: UpdateTimeMap
73
+ ): Promise<string[]> {
74
+ const conflicts: string[] = [];
75
+
76
+ const docRefs = destDocIds.map((id) => destDb.collection(destCollectionPath).doc(id));
77
+ const docs = await destDb.getAll(...docRefs);
78
+
79
+ for (let i = 0; i < docs.length; i++) {
80
+ const doc = docs[i];
81
+ const docId = destDocIds[i];
82
+ const capturedTime = capturedTimes.get(docId);
83
+
84
+ const currentTime =
85
+ doc.exists && doc.updateTime ? doc.updateTime.toDate().toISOString() : null;
86
+
87
+ // Conflict conditions:
88
+ // 1. Doc didn't exist before but now exists (created by someone else)
89
+ // 2. Doc was modified (updateTime changed)
90
+ // 3. Doc was deleted during transfer (existed before, doesn't now)
91
+ const isConflict =
92
+ (doc.exists && capturedTime === null) ||
93
+ (doc.exists && currentTime !== capturedTime) ||
94
+ (!doc.exists && capturedTime !== null);
95
+
96
+ if (isConflict) {
97
+ conflicts.push(docId);
98
+ }
99
+ }
100
+
101
+ return conflicts;
102
+ }
103
+
104
+ function buildTransferQuery(
105
+ sourceDb: Firestore,
106
+ collectionPath: string,
107
+ config: Config,
108
+ depth: number
109
+ ): Query {
110
+ let query: Query = sourceDb.collection(collectionPath);
36
111
 
37
- // Apply where filters (only at root level)
38
112
  if (depth === 0 && config.where.length > 0) {
39
113
  for (const filter of config.where) {
40
114
  query = query.where(filter.field, filter.operator, filter.value);
@@ -45,170 +119,427 @@ export async function transferCollection(
45
119
  query = query.limit(config.limit);
46
120
  }
47
121
 
48
- const snapshot = await withRetry(() => query.get(), {
122
+ return query;
123
+ }
124
+
125
+ function applyTransform(
126
+ docData: Record<string, unknown>,
127
+ doc: QueryDocumentSnapshot,
128
+ collectionPath: string,
129
+ transformFn: TransformFunction,
130
+ output: Output,
131
+ stats: Stats
132
+ ): { success: boolean; data: Record<string, unknown> | null; markCompleted: boolean } {
133
+ try {
134
+ const transformed = transformFn(docData, {
135
+ id: doc.id,
136
+ path: `${collectionPath}/${doc.id}`,
137
+ });
138
+
139
+ if (transformed === null) {
140
+ output.logInfo('Skipped document (transform returned null)', {
141
+ collection: collectionPath,
142
+ docId: doc.id,
143
+ });
144
+ return { success: false, data: null, markCompleted: true };
145
+ }
146
+
147
+ return { success: true, data: transformed, markCompleted: false };
148
+ } catch (transformError) {
149
+ const errMsg =
150
+ transformError instanceof Error ? transformError.message : String(transformError);
151
+ output.logError(`Transform failed for document ${doc.id}`, {
152
+ collection: collectionPath,
153
+ error: errMsg,
154
+ });
155
+ stats.errors++;
156
+ return { success: false, data: null, markCompleted: false };
157
+ }
158
+ }
159
+
160
+ function checkDocumentSize(
161
+ docData: Record<string, unknown>,
162
+ doc: QueryDocumentSnapshot,
163
+ collectionPath: string,
164
+ destCollectionPath: string,
165
+ destDocId: string,
166
+ config: Config,
167
+ output: Output
168
+ ): { valid: boolean; markCompleted: boolean } {
169
+ const docSize = estimateDocumentSize(docData, `${destCollectionPath}/${destDocId}`);
170
+
171
+ if (docSize <= FIRESTORE_MAX_DOC_SIZE) {
172
+ return { valid: true, markCompleted: false };
173
+ }
174
+
175
+ const sizeStr = formatBytes(docSize);
176
+ if (config.skipOversized) {
177
+ output.logInfo(`Skipped oversized document (${sizeStr})`, {
178
+ collection: collectionPath,
179
+ docId: doc.id,
180
+ });
181
+ return { valid: false, markCompleted: true };
182
+ }
183
+
184
+ throw new Error(
185
+ `Document ${collectionPath}/${doc.id} exceeds 1MB limit (${sizeStr}). Use --skip-oversized to skip.`
186
+ );
187
+ }
188
+
189
+ async function processSubcollections(
190
+ ctx: TransferContext,
191
+ doc: QueryDocumentSnapshot,
192
+ collectionPath: string,
193
+ depth: number
194
+ ): Promise<void> {
195
+ const { config, output } = ctx;
196
+
197
+ // Check max depth limit (0 = unlimited)
198
+ if (config.maxDepth > 0 && depth >= config.maxDepth) {
199
+ output.logInfo(`Skipping subcollections at depth ${depth} (max: ${config.maxDepth})`, {
200
+ collection: collectionPath,
201
+ docId: doc.id,
202
+ });
203
+ return;
204
+ }
205
+
206
+ const subcollections = await getSubcollections(doc.ref);
207
+
208
+ for (const subcollectionId of subcollections) {
209
+ if (matchesExcludePattern(subcollectionId, config.exclude)) {
210
+ output.logInfo(`Skipping excluded subcollection: ${subcollectionId}`);
211
+ continue;
212
+ }
213
+
214
+ const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
215
+ const subCtx = { ...ctx, config: { ...config, limit: 0, where: [] } };
216
+ await transferCollection(subCtx, subcollectionPath, depth + 1);
217
+ }
218
+ }
219
+
220
+ function processDocument(
221
+ doc: QueryDocumentSnapshot,
222
+ ctx: TransferContext,
223
+ collectionPath: string,
224
+ destCollectionPath: string
225
+ ): DocProcessResult {
226
+ const { config, output, stateSaver, stats, transformFn } = ctx;
227
+
228
+ // Skip if already completed (resume mode) - O(1) lookup via Set
229
+ if (stateSaver?.isCompleted(collectionPath, doc.id)) {
230
+ stats.documentsTransferred++;
231
+ return { skip: true, markCompleted: false };
232
+ }
233
+
234
+ const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
235
+ let docData = doc.data() as Record<string, unknown>;
236
+
237
+ // Apply transform if provided
238
+ if (transformFn) {
239
+ const transformResult = applyTransform(
240
+ docData,
241
+ doc,
242
+ collectionPath,
243
+ transformFn,
244
+ output,
245
+ stats
246
+ );
247
+ if (!transformResult.success) {
248
+ return { skip: true, markCompleted: transformResult.markCompleted };
249
+ }
250
+ docData = transformResult.data!;
251
+ }
252
+
253
+ // Check document size
254
+ const sizeResult = checkDocumentSize(
255
+ docData,
256
+ doc,
257
+ collectionPath,
258
+ destCollectionPath,
259
+ destDocId,
260
+ config,
261
+ output
262
+ );
263
+ if (!sizeResult.valid) {
264
+ return { skip: true, markCompleted: sizeResult.markCompleted };
265
+ }
266
+
267
+ return { skip: false, data: docData, markCompleted: true };
268
+ }
269
+
270
+ function incrementProgress(progressBar: ProgressBarWrapper): void {
271
+ progressBar.increment();
272
+ }
273
+
274
+ async function commitBatchWithRetry(
275
+ destBatch: WriteBatch,
276
+ batchDocIds: string[],
277
+ ctx: TransferContext,
278
+ collectionPath: string
279
+ ): Promise<void> {
280
+ const { config, output, stateSaver, stats, rateLimiter } = ctx;
281
+
282
+ if (rateLimiter) {
283
+ await rateLimiter.acquire(batchDocIds.length);
284
+ }
285
+
286
+ await withRetry(() => destBatch.commit(), {
49
287
  retries: config.retries,
50
288
  onRetry: (attempt, max, err, delay) => {
51
- logger.error(`Retry ${attempt}/${max} for ${collectionPath}`, {
52
- error: err.message,
53
- delay,
54
- });
289
+ output.logError(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
55
290
  },
56
291
  });
57
292
 
58
- if (snapshot.empty) {
59
- return;
293
+ if (stateSaver && batchDocIds.length > 0) {
294
+ stateSaver.markBatchCompleted(collectionPath, batchDocIds, stats);
295
+ }
296
+ }
297
+
298
+ function addDocToBatch(
299
+ destBatch: FirebaseFirestore.WriteBatch,
300
+ destDb: Firestore,
301
+ destCollectionPath: string,
302
+ destDocId: string,
303
+ data: Record<string, unknown>,
304
+ merge: boolean
305
+ ): void {
306
+ const destDocRef = destDb.collection(destCollectionPath).doc(destDocId);
307
+ if (merge) {
308
+ destBatch.set(destDocRef, data, { merge: true });
309
+ } else {
310
+ destBatch.set(destDocRef, data);
60
311
  }
312
+ }
61
313
 
62
- stats.collectionsProcessed++;
63
- logger.info(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
314
+ interface PreparedDoc {
315
+ sourceDoc: QueryDocumentSnapshot;
316
+ sourceDocId: string;
317
+ destDocId: string;
318
+ data: Record<string, unknown>;
319
+ sourceHash?: string;
320
+ }
64
321
 
65
- const docs = snapshot.docs;
66
- const batchDocIds: string[] = []; // Track docs in current batch for state saving
322
+ async function prepareDocForTransfer(
323
+ doc: QueryDocumentSnapshot,
324
+ ctx: TransferContext,
325
+ collectionPath: string,
326
+ destCollectionPath: string
327
+ ): Promise<PreparedDoc | null> {
328
+ const { config, progressBar } = ctx;
329
+ const result = processDocument(doc, ctx, collectionPath, destCollectionPath);
330
+ incrementProgress(progressBar);
67
331
 
68
- for (let i = 0; i < docs.length; i += config.batchSize) {
69
- const batch = docs.slice(i, i + config.batchSize);
70
- const destBatch: WriteBatch = destDb.batch();
71
- batchDocIds.length = 0; // Clear for new batch
332
+ if (result.skip) {
333
+ return null;
334
+ }
72
335
 
73
- for (const doc of batch) {
74
- // Skip if already completed (resume mode)
75
- if (state && isDocCompleted(state, collectionPath, doc.id)) {
76
- if (progressBar) {
77
- progressBar.increment();
78
- }
79
- stats.documentsTransferred++;
80
- continue;
81
- }
336
+ const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
337
+ const prepared: PreparedDoc = {
338
+ sourceDoc: doc,
339
+ sourceDocId: doc.id,
340
+ destDocId,
341
+ data: result.data!,
342
+ };
82
343
 
83
- // Get destination document ID (with optional prefix/suffix)
84
- const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
85
- const destDocRef = destDb.collection(destCollectionPath).doc(destDocId);
86
-
87
- // Apply transform if provided
88
- let docData = doc.data() as Record<string, unknown>;
89
- if (transformFn) {
90
- try {
91
- const transformed = transformFn(docData, {
92
- id: doc.id,
93
- path: `${collectionPath}/${doc.id}`,
94
- });
95
- if (transformed === null) {
96
- // Skip this document if transform returns null
97
- logger.info('Skipped document (transform returned null)', {
98
- collection: collectionPath,
99
- docId: doc.id,
100
- });
101
- if (progressBar) {
102
- progressBar.increment();
103
- }
104
- // Mark as completed even if skipped
105
- batchDocIds.push(doc.id);
106
- continue;
107
- }
108
- docData = transformed;
109
- } catch (transformError) {
110
- const errMsg =
111
- transformError instanceof Error
112
- ? transformError.message
113
- : String(transformError);
114
- logger.error(`Transform failed for document ${doc.id}`, {
115
- collection: collectionPath,
116
- error: errMsg,
117
- });
118
- stats.errors++;
119
- if (progressBar) {
120
- progressBar.increment();
121
- }
122
- // Skip this document but continue with others
123
- continue;
124
- }
125
- }
344
+ // Compute source hash if integrity verification is enabled
345
+ if (config.verifyIntegrity) {
346
+ prepared.sourceHash = hashDocumentData(result.data!);
347
+ }
126
348
 
127
- // Check document size
128
- const docSize = estimateDocumentSize(docData, `${destCollectionPath}/${destDocId}`);
129
- if (docSize > FIRESTORE_MAX_DOC_SIZE) {
130
- const sizeStr = formatBytes(docSize);
131
- if (config.skipOversized) {
132
- logger.info(`Skipped oversized document (${sizeStr})`, {
133
- collection: collectionPath,
134
- docId: doc.id,
135
- });
136
- if (progressBar) {
137
- progressBar.increment();
138
- }
139
- batchDocIds.push(doc.id);
140
- continue;
141
- } else {
142
- throw new Error(
143
- `Document ${collectionPath}/${doc.id} exceeds 1MB limit (${sizeStr}). Use --skip-oversized to skip.`
144
- );
145
- }
146
- }
349
+ return prepared;
350
+ }
147
351
 
148
- if (!config.dryRun) {
149
- // Use merge option if enabled
150
- if (config.merge) {
151
- destBatch.set(destDocRef, docData, { merge: true });
152
- } else {
153
- destBatch.set(destDocRef, docData);
154
- }
155
- }
352
+ async function verifyBatchIntegrity(
353
+ preparedDocs: PreparedDoc[],
354
+ destDb: Firestore,
355
+ destCollectionPath: string,
356
+ stats: Stats,
357
+ output: Output
358
+ ): Promise<void> {
359
+ const docRefs = preparedDocs.map((p) => destDb.collection(destCollectionPath).doc(p.destDocId));
360
+ const destDocs = await destDb.getAll(...docRefs);
156
361
 
157
- batchDocIds.push(doc.id);
158
- stats.documentsTransferred++;
159
- if (progressBar) {
160
- progressBar.increment();
161
- }
362
+ for (let i = 0; i < destDocs.length; i++) {
363
+ const prepared = preparedDocs[i];
364
+ const destDoc = destDocs[i];
365
+
366
+ if (!destDoc.exists) {
367
+ stats.integrityErrors++;
368
+ output.warn(
369
+ `⚠️ Integrity error: ${destCollectionPath}/${prepared.destDocId} not found after write`
370
+ );
371
+ output.logError('Integrity verification failed', {
372
+ collection: destCollectionPath,
373
+ docId: prepared.destDocId,
374
+ reason: 'document_not_found',
375
+ });
376
+ continue;
377
+ }
378
+
379
+ const destData = destDoc.data() as Record<string, unknown>;
380
+ const destHash = hashDocumentData(destData);
162
381
 
163
- logger.info('Transferred document', {
164
- source: collectionPath,
165
- dest: destCollectionPath,
166
- sourceDocId: doc.id,
167
- destDocId: destDocId,
382
+ if (!compareHashes(prepared.sourceHash!, destHash)) {
383
+ stats.integrityErrors++;
384
+ output.warn(
385
+ `⚠️ Integrity error: ${destCollectionPath}/${prepared.destDocId} hash mismatch`
386
+ );
387
+ output.logError('Integrity verification failed', {
388
+ collection: destCollectionPath,
389
+ docId: prepared.destDocId,
390
+ reason: 'hash_mismatch',
391
+ sourceHash: prepared.sourceHash,
392
+ destHash,
168
393
  });
394
+ }
395
+ }
396
+ }
397
+
398
+ async function commitPreparedDocs(
399
+ preparedDocs: PreparedDoc[],
400
+ ctx: TransferContext,
401
+ collectionPath: string,
402
+ destCollectionPath: string,
403
+ depth: number
404
+ ): Promise<string[]> {
405
+ const { destDb, config, stats, output } = ctx;
406
+ const destBatch = destDb.batch();
407
+ const batchDocIds: string[] = [];
408
+
409
+ for (const prepared of preparedDocs) {
410
+ if (!config.dryRun) {
411
+ addDocToBatch(
412
+ destBatch,
413
+ destDb,
414
+ destCollectionPath,
415
+ prepared.destDocId,
416
+ prepared.data,
417
+ config.merge
418
+ );
419
+ }
420
+
421
+ batchDocIds.push(prepared.sourceDocId);
422
+ stats.documentsTransferred++;
423
+
424
+ output.logInfo('Transferred document', {
425
+ source: collectionPath,
426
+ dest: destCollectionPath,
427
+ sourceDocId: prepared.sourceDocId,
428
+ destDocId: prepared.destDocId,
429
+ });
430
+
431
+ if (config.includeSubcollections) {
432
+ await processSubcollections(ctx, prepared.sourceDoc, collectionPath, depth);
433
+ }
434
+ }
169
435
 
170
- if (config.includeSubcollections) {
171
- const subcollections = await getSubcollections(doc.ref);
436
+ if (!config.dryRun && preparedDocs.length > 0) {
437
+ await commitBatchWithRetry(destBatch, batchDocIds, ctx, collectionPath);
172
438
 
173
- for (const subcollectionId of subcollections) {
174
- // Check exclude patterns
175
- if (matchesExcludePattern(subcollectionId, config.exclude)) {
176
- logger.info(`Skipping excluded subcollection: ${subcollectionId}`);
177
- continue;
178
- }
439
+ // Verify integrity after commit if enabled
440
+ if (config.verifyIntegrity) {
441
+ await verifyBatchIntegrity(preparedDocs, destDb, destCollectionPath, stats, output);
442
+ }
443
+ }
444
+
445
+ return batchDocIds;
446
+ }
179
447
 
180
- const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
448
+ async function processBatch(
449
+ batch: QueryDocumentSnapshot[],
450
+ ctx: TransferContext,
451
+ collectionPath: string,
452
+ destCollectionPath: string,
453
+ depth: number
454
+ ): Promise<string[]> {
455
+ const { destDb, config, stats, output, conflictList } = ctx;
456
+
457
+ // Step 1: Prepare all docs for transfer
458
+ const preparedDocs: PreparedDoc[] = [];
459
+ for (const doc of batch) {
460
+ const prepared = await prepareDocForTransfer(doc, ctx, collectionPath, destCollectionPath);
461
+ if (prepared) {
462
+ preparedDocs.push(prepared);
463
+ }
464
+ }
465
+
466
+ if (preparedDocs.length === 0) {
467
+ return [];
468
+ }
181
469
 
182
- await transferCollection(
183
- { ...ctx, config: { ...config, limit: 0, where: [] } },
184
- subcollectionPath,
185
- depth + 1
470
+ // Step 2: If conflict detection is enabled, capture dest updateTimes and check for conflicts
471
+ let docsToWrite = preparedDocs;
472
+ if (config.detectConflicts && !config.dryRun) {
473
+ const destDocIds = preparedDocs.map((p) => p.destDocId);
474
+ const capturedTimes = await captureDestUpdateTimes(destDb, destCollectionPath, destDocIds);
475
+
476
+ // Check for conflicts
477
+ const conflictingIds = await checkForConflicts(
478
+ destDb,
479
+ destCollectionPath,
480
+ destDocIds,
481
+ capturedTimes
482
+ );
483
+
484
+ if (conflictingIds.length > 0) {
485
+ const conflictSet = new Set(conflictingIds);
486
+
487
+ // Filter out conflicting docs
488
+ docsToWrite = preparedDocs.filter((p) => !conflictSet.has(p.destDocId));
489
+
490
+ // Record conflicts
491
+ for (const prepared of preparedDocs) {
492
+ if (conflictSet.has(prepared.destDocId)) {
493
+ stats.conflicts++;
494
+ conflictList.push({
495
+ collection: destCollectionPath,
496
+ docId: prepared.destDocId,
497
+ reason: 'Document was modified during transfer',
498
+ });
499
+ output.warn(
500
+ `⚠️ Conflict detected: ${destCollectionPath}/${prepared.destDocId} was modified during transfer`
186
501
  );
502
+ output.logError('Conflict detected', {
503
+ collection: destCollectionPath,
504
+ docId: prepared.destDocId,
505
+ reason: 'modified_during_transfer',
506
+ });
187
507
  }
188
508
  }
189
509
  }
510
+ }
190
511
 
191
- if (!config.dryRun && batch.length > 0) {
192
- // Apply rate limiting before commit
193
- if (rateLimiter) {
194
- await rateLimiter.acquire(batchDocIds.length);
195
- }
512
+ // Step 3: Commit non-conflicting docs
513
+ return commitPreparedDocs(docsToWrite, ctx, collectionPath, destCollectionPath, depth);
514
+ }
196
515
 
197
- await withRetry(() => destBatch.commit(), {
198
- retries: config.retries,
199
- onRetry: (attempt, max, err, delay) => {
200
- logger.error(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
201
- },
516
+ export async function transferCollection(
517
+ ctx: TransferContext,
518
+ collectionPath: string,
519
+ depth: number = 0
520
+ ): Promise<void> {
521
+ const { sourceDb, config, stats, output } = ctx;
522
+ const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
523
+
524
+ const query = buildTransferQuery(sourceDb, collectionPath, config, depth);
525
+
526
+ const snapshot = await withRetry(() => query.get(), {
527
+ retries: config.retries,
528
+ onRetry: (attempt, max, err, delay) => {
529
+ output.logError(`Retry ${attempt}/${max} for ${collectionPath}`, {
530
+ error: err.message,
531
+ delay,
202
532
  });
533
+ },
534
+ });
203
535
 
204
- // Save state after successful batch commit (for resume support)
205
- if (state && batchDocIds.length > 0) {
206
- for (const docId of batchDocIds) {
207
- markDocCompleted(state, collectionPath, docId);
208
- }
209
- state.stats = { ...stats };
210
- saveTransferState(config.stateFile, state);
211
- }
212
- }
536
+ if (snapshot.empty) return;
537
+
538
+ stats.collectionsProcessed++;
539
+ output.logInfo(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
540
+
541
+ for (let i = 0; i < snapshot.docs.length; i += config.batchSize) {
542
+ const batch = snapshot.docs.slice(i, i + config.batchSize);
543
+ await processBatch(batch, ctx, collectionPath, destCollectionPath, depth);
213
544
  }
214
545
  }