@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.
@@ -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.dryRun && config.transformSamples !== 0) {
187
+ if (transformFn && config.transformSamples !== 0) {
160
188
  await validateTransformWithSamples(sourceDb, config, transformFn, output);
161
189
  }
162
190
 
163
- const currentStats = config.resume ? stats : createEmptyStats();
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, stateSaver, rateLimiter, conflictList
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, createEmptyStats(), duration, errorMessage, output);
240
+ await handleErrorOutput(config, currentStats, duration, errorMessage, output);
210
241
  await cleanupFirebase();
211
242
 
212
- return { success: false, stats: createEmptyStats(), duration, error: errorMessage };
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
- for (const collection of config.collections) {
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
- }
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
- 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();
345
+ totalDocs += rootCount;
346
+ output.info(` ${collection}: ${rootCount} documents (+ subcollections)`);
339
347
  }
340
348
 
341
- // Print collection summary
342
- output.info(` ${collection}: ${rootCount} documents`);
343
-
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)}`);
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
  }
@@ -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
- const state = JSON.parse(content) as TransferState;
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
- console.warn(
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
- console.error(`⚠️ Failed to load state file: ${(error as Error).message}`);
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
- console.error(`⚠️ Failed to save state file: ${(error as Error).message}`);
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 function validateStateForResume(state: TransferState, config: Config): string[] {
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 collections are compatible (state collections should be subset of config)
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
- return errors;
288
- }
289
-
290
- export function isDocCompleted(
291
- state: TransferState,
292
- collectionPath: string,
293
- docId: string
294
- ): boolean {
295
- const completedInCollection = state.completedDocs[collectionPath];
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
- state.completedDocs[collectionPath].push(docId);
322
+
323
+ return { errors, warnings };
308
324
  }
@@ -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 { matchesExcludePattern } from '../utils/patterns.js';
6
- import { getSubcollections, getDestCollectionPath } from './helpers.js';
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 getSubcollections(doc.ref);
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
- // Delete subcollections first if enabled
70
- if (includeSubcollections) {
71
- for (const doc of snapshot.docs) {
72
- deletedCount += await clearDocSubcollections(db, doc, collectionPath, config, output);
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
- return deletedCount;
83
- }
84
-
85
- async function clearOrphanSubcollections(
86
- destDb: Firestore,
87
- doc: QueryDocumentSnapshot,
88
- destCollectionPath: string,
89
- config: Config,
90
- output: Output
91
- ): Promise<number> {
92
- let deletedCount = 0;
93
- const subcollections = await getSubcollections(doc.ref);
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
- for (const subId of subcollections) {
96
- if (matchesExcludePattern(subId, config.exclude)) continue;
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
- const subPath = `${destCollectionPath}/${doc.id}/${subId}`;
99
- deletedCount += await clearCollection(destDb, subPath, config, output, true);
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 clearOrphanSubcollections(
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 getSubcollections(sourceDoc.ref);
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
- const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).select().get();
200
- const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
201
-
202
- const destSnapshot = await destDb.collection(destCollectionPath).select().get();
203
- const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
204
-
205
- progress?.onScanComplete?.(destCollectionPath, orphanDocs.length, destSnapshot.size);
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
- if (orphanDocs.length > 0) {
210
- output.logInfo(`Found ${orphanDocs.length} orphan documents in ${destCollectionPath}`);
243
+ lastDestDoc = snapshot.docs[snapshot.docs.length - 1];
244
+ if (snapshot.size < CLEAR_PAGE_SIZE) break;
245
+ }
211
246
 
212
- for (let i = 0; i < orphanDocs.length; i += config.batchSize) {
213
- const batch = orphanDocs.slice(i, i + config.batchSize);
214
- deletedCount += await deleteOrphanBatch(
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
- batch,
217
- destCollectionPath,
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
- if (config.includeSubcollections) {
226
- deletedCount += await processSubcollectionOrphansWithProgress(
227
- sourceDb,
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;
@@ -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
- // Apply limit at root level only
36
- if (depth === 0 && config.limit > 0) {
37
- query = query.limit(config.limit);
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
- const snapshot = await query.select().get();
41
- let count = snapshot.size;
34
+ let pageQuery = query.select().limit(pageSize);
35
+ if (lastDoc) {
36
+ pageQuery = pageQuery.startAfter(lastDoc);
37
+ }
42
38
 
43
- if (depth === 0 && progress?.onCollection) {
44
- progress.onCollection(collectionPath, snapshot.size);
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
- for (const doc of snapshot.docs) {
48
- count += await countSubcollectionsForDoc(
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 count;
67
+ return rootCount + subCount;
59
68
  }
60
69
 
61
70
  async function countSubcollectionsForDoc(