@fazetitans/fscopy 1.2.0 → 1.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fazetitans/fscopy",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Fast CLI tool to copy Firestore collections between Firebase projects with filtering, parallel transfers, and subcollection support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,9 +11,10 @@
11
11
  "dev": "bun --watch src/cli.ts",
12
12
  "test": "bun test",
13
13
  "test:watch": "bun test --watch",
14
+ "test:coverage": "bun test --coverage",
14
15
  "type-check": "tsc --noEmit",
15
- "lint": "eslint src/**/*.ts --ignore-pattern 'src/__tests__/**' --no-warn-ignored",
16
- "lint:fix": "eslint src/**/*.ts --ignore-pattern 'src/__tests__/**' --no-warn-ignored --fix",
16
+ "lint": "eslint src/**/*.ts --no-warn-ignored",
17
+ "lint:fix": "eslint src/**/*.ts --no-warn-ignored --fix",
17
18
  "format": "prettier --write src/**/*.ts",
18
19
  "format:check": "prettier --check src/**/*.ts",
19
20
  "prepublishOnly": "bun run type-check && bun run lint && bun test"
@@ -43,7 +44,8 @@
43
44
  "url": "https://github.com/FaZeTitans/fscopy/issues"
44
45
  },
45
46
  "engines": {
46
- "node": ">=18.0.0"
47
+ "node": ">=18.0.0",
48
+ "bun": ">=1.0.0"
47
49
  },
48
50
  "files": [
49
51
  "src/**/*.ts",
package/src/cli.ts CHANGED
@@ -6,11 +6,12 @@ process.env.METADATA_SERVER_DETECTION = 'none';
6
6
  import yargs from 'yargs';
7
7
  import { hideBin } from 'yargs/helpers';
8
8
 
9
- import type { Config, CliArgs } from './types.js';
9
+ import pkg from '../package.json';
10
+ import type { Config, CliArgs, ValidatedConfig } from './types.js';
10
11
  import { Output, parseSize } from './utils/output.js';
11
12
  import { ensureCredentials } from './utils/credentials.js';
12
13
  import { loadConfigFile, mergeConfig } from './config/parser.js';
13
- import { validateConfig } from './config/validator.js';
14
+ import { validateConfig, isValidatedConfig } from './config/validator.js';
14
15
  import { defaults } from './config/defaults.js';
15
16
  import { generateConfigFile } from './config/generator.js';
16
17
  import { validateWebhookUrl } from './webhook/index.js';
@@ -24,6 +25,7 @@ import { runTransfer } from './orchestrator.js';
24
25
 
25
26
  const argv = yargs(hideBin(process.argv))
26
27
  .scriptName('fscopy')
28
+ .version(pkg.version)
27
29
  .usage('$0 [options]')
28
30
  .option('init', {
29
31
  type: 'string',
@@ -265,9 +267,16 @@ async function main(): Promise<void> {
265
267
  process.exit(1);
266
268
  }
267
269
 
270
+ // After validation, config is guaranteed to have required fields
271
+ if (!isValidatedConfig(config)) {
272
+ console.log('\n❌ Configuration validation failed');
273
+ process.exit(1);
274
+ }
275
+ const validConfig: ValidatedConfig = config;
276
+
268
277
  // Validate webhook URL if configured
269
- if (config.webhook) {
270
- const webhookValidation = validateWebhookUrl(config.webhook);
278
+ if (validConfig.webhook) {
279
+ const webhookValidation = validateWebhookUrl(validConfig.webhook);
271
280
  if (!webhookValidation.valid) {
272
281
  console.log(`\n❌ ${webhookValidation.warning}`);
273
282
  process.exit(1);
@@ -285,7 +294,7 @@ async function main(): Promise<void> {
285
294
 
286
295
  // Skip confirmation in interactive mode (already confirmed by selection)
287
296
  if (!argv.yes && !argv.interactive) {
288
- const confirmed = await askConfirmation(config);
297
+ const confirmed = await askConfirmation(validConfig);
289
298
  if (!confirmed) {
290
299
  console.log('\n🚫 Transfer cancelled by user\n');
291
300
  process.exit(0);
@@ -300,10 +309,10 @@ async function main(): Promise<void> {
300
309
  maxLogSize: parseSize(argv.maxLogSize),
301
310
  });
302
311
  output.init();
303
- output.logInfo('Transfer started', { config: config as unknown as Record<string, unknown> });
312
+ output.logInfo('Transfer started', { config: validConfig as unknown as Record<string, unknown> });
304
313
 
305
314
  // Run transfer
306
- const result = await runTransfer(config, argv, output);
315
+ const result = await runTransfer(validConfig, argv, output);
307
316
 
308
317
  if (!result.success) {
309
318
  process.exit(1);
@@ -11,5 +11,5 @@ export {
11
11
  loadConfigFile,
12
12
  mergeConfig,
13
13
  } from './parser.js';
14
- export { validateConfig } from './validator.js';
14
+ export { validateConfig, isValidatedConfig, assertValidConfig } from './validator.js';
15
15
  export { generateConfigFile } from './generator.js';
@@ -1,4 +1,4 @@
1
- import type { Config } from '../types.js';
1
+ import type { Config, ValidatedConfig } from '../types.js';
2
2
 
3
3
  /**
4
4
  * Validate a Firestore collection or document ID.
@@ -79,3 +79,29 @@ export function validateConfig(config: Config): string[] {
79
79
 
80
80
  return errors;
81
81
  }
82
+
83
+ /**
84
+ * Type guard to check if a Config has been validated.
85
+ * Returns true if sourceProject and destProject are non-null strings.
86
+ */
87
+ export function isValidatedConfig(config: Config): config is ValidatedConfig {
88
+ return (
89
+ typeof config.sourceProject === 'string' &&
90
+ typeof config.destProject === 'string' &&
91
+ config.collections.length > 0
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Validates config and returns ValidatedConfig if valid, throws otherwise.
97
+ */
98
+ export function assertValidConfig(config: Config): ValidatedConfig {
99
+ const errors = validateConfig(config);
100
+ if (errors.length > 0) {
101
+ throw new Error(`Invalid configuration: ${errors.join(', ')}`);
102
+ }
103
+ if (!isValidatedConfig(config)) {
104
+ throw new Error('Configuration validation failed');
105
+ }
106
+ return config;
107
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Application-wide constants.
3
+ * Centralizes magic numbers for better maintainability.
4
+ */
5
+
6
+ // =============================================================================
7
+ // Display Constants
8
+ // =============================================================================
9
+
10
+ /** Width for separator lines and progress line clearing */
11
+ export const SEPARATOR_LENGTH = 60;
12
+
13
+ // =============================================================================
14
+ // Timing Constants
15
+ // =============================================================================
16
+
17
+ /** Interval for logging subcollection/progress updates during scanning (ms) */
18
+ export const PROGRESS_LOG_INTERVAL_MS = 2000;
19
+
20
+ /** Interval for updating speed display in progress bar (ms) */
21
+ export const SPEED_UPDATE_INTERVAL_MS = 500;
22
+
23
+ /** Interval for flushing batched progress bar increments (ms) */
24
+ export const PROGRESS_FLUSH_INTERVAL_MS = 50;
25
+
26
+ /** Default interval for auto-saving transfer state (ms) */
27
+ export const STATE_SAVE_INTERVAL_MS = 5000;
28
+
29
+ /** Default number of batches between state saves */
30
+ export const STATE_SAVE_BATCH_INTERVAL = 10;
@@ -1,6 +1,7 @@
1
1
  import admin from 'firebase-admin';
2
2
  import type { Firestore } from 'firebase-admin/firestore';
3
3
  import { input, checkbox, confirm } from '@inquirer/prompts';
4
+ import { SEPARATOR_LENGTH } from './constants.js';
4
5
  import type { Config } from './types.js';
5
6
 
6
7
  async function promptForProject(
@@ -59,9 +60,9 @@ async function promptForIdModification(
59
60
  }
60
61
 
61
62
  export async function runInteractiveMode(config: Config): Promise<Config> {
62
- console.log('\n' + '='.repeat(60));
63
+ console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
63
64
  console.log('🔄 FSCOPY - INTERACTIVE MODE');
64
- console.log('='.repeat(60) + '\n');
65
+ console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
65
66
 
66
67
  const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '📤');
67
68
  const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '📥');
@@ -1,12 +1,13 @@
1
1
  import type { Firestore } from 'firebase-admin/firestore';
2
2
 
3
- import type { Config, Stats, TransferState, TransformFunction, CliArgs, ConflictInfo } from './types.js';
3
+ import { PROGRESS_LOG_INTERVAL_MS, SEPARATOR_LENGTH } from './constants.js';
4
+ import type { Config, ValidatedConfig, Stats, TransferState, TransformFunction, CliArgs, ConflictInfo } from './types.js';
4
5
  import { Output } from './utils/output.js';
5
6
  import { RateLimiter } from './utils/rate-limiter.js';
6
7
  import { ProgressBarWrapper } from './utils/progress.js';
7
8
  import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState, StateSaver } from './state/index.js';
8
9
  import { sendWebhook } from './webhook/index.js';
9
- import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress } from './transfer/index.js';
10
+ import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress, type DeleteOrphansProgress } from './transfer/index.js';
10
11
  import { initializeFirebase, checkDatabaseConnectivity, cleanupFirebase } from './firebase/index.js';
11
12
  import { loadTransformFunction } from './transform/loader.js';
12
13
  import { printSummary, formatJsonOutput } from './output/display.js';
@@ -24,7 +25,7 @@ interface ResumeResult {
24
25
  stats: Stats;
25
26
  }
26
27
 
27
- function initializeResumeMode(config: Config, output: Output): ResumeResult {
28
+ function initializeResumeMode(config: ValidatedConfig, output: Output): ResumeResult {
28
29
  if (config.resume) {
29
30
  const existingState = loadTransferState(config.stateFile);
30
31
  if (!existingState) {
@@ -68,7 +69,7 @@ async function loadTransform(config: Config, output: Output): Promise<TransformF
68
69
  }
69
70
 
70
71
  async function handleSuccessOutput(
71
- config: Config,
72
+ config: ValidatedConfig,
72
73
  argv: CliArgs,
73
74
  stats: Stats,
74
75
  duration: number,
@@ -78,13 +79,13 @@ async function handleSuccessOutput(
78
79
  if (config.json) {
79
80
  output.json(JSON.parse(formatJsonOutput(true, config, stats, duration, undefined, verifyResult)));
80
81
  } else {
81
- printSummary(stats, duration.toFixed(2), argv.log, config.dryRun);
82
+ printSummary(stats, duration.toFixed(2), argv.log, config.dryRun, config.verifyIntegrity);
82
83
  }
83
84
 
84
85
  if (config.webhook) {
85
86
  await sendWebhook(config.webhook, {
86
- source: config.sourceProject!,
87
- destination: config.destProject!,
87
+ source: config.sourceProject,
88
+ destination: config.destProject,
88
89
  collections: config.collections,
89
90
  stats,
90
91
  duration,
@@ -95,7 +96,7 @@ async function handleSuccessOutput(
95
96
  }
96
97
 
97
98
  async function handleErrorOutput(
98
- config: Config,
99
+ config: ValidatedConfig,
99
100
  stats: Stats,
100
101
  duration: number,
101
102
  errorMessage: string,
@@ -109,8 +110,8 @@ async function handleErrorOutput(
109
110
 
110
111
  if (config.webhook) {
111
112
  await sendWebhook(config.webhook, {
112
- source: config.sourceProject ?? 'unknown',
113
- destination: config.destProject ?? 'unknown',
113
+ source: config.sourceProject,
114
+ destination: config.destProject,
114
115
  collections: config.collections,
115
116
  stats,
116
117
  duration,
@@ -121,7 +122,30 @@ async function handleErrorOutput(
121
122
  }
122
123
  }
123
124
 
124
- export async function runTransfer(config: Config, argv: CliArgs, output: Output): Promise<TransferResult> {
125
+ function displayTransferOptions(config: Config, rateLimiter: RateLimiter | null, output: Output): void {
126
+ let hasOptions = false;
127
+
128
+ if (rateLimiter) {
129
+ output.info(`⏱️ Rate limiting enabled: ${config.rateLimit} docs/s`);
130
+ hasOptions = true;
131
+ }
132
+
133
+ if (config.detectConflicts) {
134
+ output.info('🔒 Conflict detection enabled: Conflicts will be logged but won\'t stop the transfer');
135
+ hasOptions = true;
136
+ }
137
+
138
+ if (config.maxDepth > 0 && config.includeSubcollections) {
139
+ output.info(`📊 Max depth: ${config.maxDepth} - Subcollections beyond this level will be skipped`);
140
+ hasOptions = true;
141
+ }
142
+
143
+ if (hasOptions) {
144
+ output.blank();
145
+ }
146
+ }
147
+
148
+ export async function runTransfer(config: ValidatedConfig, argv: CliArgs, output: Output): Promise<TransferResult> {
125
149
  const startTime = Date.now();
126
150
 
127
151
  try {
@@ -137,16 +161,15 @@ export async function runTransfer(config: Config, argv: CliArgs, output: Output)
137
161
  }
138
162
 
139
163
  const currentStats = config.resume ? stats : createEmptyStats();
140
- const { progressBar } = await setupProgressTracking(sourceDb, config, currentStats, output);
141
164
 
142
165
  if (config.clear) {
143
166
  await clearDestinationCollections(destDb, config, currentStats, output);
144
167
  }
145
168
 
169
+ const { progressBar } = await setupProgressTracking(sourceDb, config, currentStats, output);
170
+
146
171
  const rateLimiter = config.rateLimit > 0 ? new RateLimiter(config.rateLimit) : null;
147
- if (rateLimiter) {
148
- output.info(`⏱️ Rate limiting enabled: ${config.rateLimit} docs/s\n`);
149
- }
172
+ displayTransferOptions(config, rateLimiter, output);
150
173
 
151
174
  const stateSaver = transferState ? new StateSaver(config.stateFile, transferState) : null;
152
175
 
@@ -263,7 +286,7 @@ async function setupProgressTracking(
263
286
  onSubcollection: (_path) => {
264
287
  subcollectionCount++;
265
288
  const now = Date.now();
266
- if (now - lastSubcollectionLog > 2000) {
289
+ if (now - lastSubcollectionLog > PROGRESS_LOG_INTERVAL_MS) {
267
290
  process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
268
291
  lastSubcollectionLog = now;
269
292
  }
@@ -275,7 +298,7 @@ async function setupProgressTracking(
275
298
  }
276
299
 
277
300
  if (subcollectionCount > 0) {
278
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
301
+ process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
279
302
  output.info(` Subcollections scanned: ${subcollectionCount}`);
280
303
  }
281
304
  output.info(` Total: ${totalDocs} documents to transfer\n`);
@@ -349,16 +372,49 @@ async function deleteOrphanDocs(
349
372
  output: Output
350
373
  ): Promise<void> {
351
374
  output.info('\n🔄 Deleting orphan documents (sync mode)...');
375
+
376
+ let lastProgressLog = Date.now();
377
+ let subcollectionCount = 0;
378
+
379
+ const progress: DeleteOrphansProgress = {
380
+ onScanStart: (collection) => {
381
+ process.stdout.write(` Scanning ${collection}...`);
382
+ },
383
+ onScanComplete: (collection, orphanCount, totalDest) => {
384
+ process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
385
+ },
386
+ onBatchDeleted: (collection, deletedSoFar, total) => {
387
+ process.stdout.write(`\r Deleting from ${collection}... ${deletedSoFar}/${total}`);
388
+ if (deletedSoFar === total) {
389
+ process.stdout.write('\n');
390
+ }
391
+ },
392
+ onSubcollectionScan: (_path) => {
393
+ subcollectionCount++;
394
+ const now = Date.now();
395
+ if (now - lastProgressLog > PROGRESS_LOG_INTERVAL_MS) {
396
+ process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} checked)`);
397
+ lastProgressLog = now;
398
+ }
399
+ },
400
+ };
401
+
352
402
  for (const collection of config.collections) {
353
403
  const deleted = await deleteOrphanDocuments(
354
404
  sourceDb,
355
405
  destDb,
356
406
  collection,
357
407
  config,
358
- output
408
+ output,
409
+ progress
359
410
  );
360
411
  stats.documentsDeleted += deleted;
361
412
  }
413
+
414
+ if (subcollectionCount > 0) {
415
+ process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
416
+ }
417
+
362
418
  if (stats.documentsDeleted > 0) {
363
419
  output.info(` Deleted ${stats.documentsDeleted} orphan documents`);
364
420
  } else {
@@ -1,4 +1,5 @@
1
1
  import readline from 'node:readline';
2
+ import { SEPARATOR_LENGTH } from '../constants.js';
2
3
  import type { Config, Stats } from '../types.js';
3
4
 
4
5
  function formatIdModification(config: Config): string | null {
@@ -113,9 +114,9 @@ function displayAdditionalOptions(config: Config): void {
113
114
  }
114
115
 
115
116
  export function displayConfig(config: Config): void {
116
- console.log('='.repeat(60));
117
+ console.log('='.repeat(SEPARATOR_LENGTH));
117
118
  console.log('🔄 FSCOPY - CONFIGURATION');
118
- console.log('='.repeat(60));
119
+ console.log('='.repeat(SEPARATOR_LENGTH));
119
120
  console.log('');
120
121
  console.log(` 📤 Source project: ${config.sourceProject || '(not set)'}`);
121
122
  console.log(` 📥 Destination project: ${config.destProject || '(not set)'}`);
@@ -137,7 +138,7 @@ export function displayConfig(config: Config): void {
137
138
  : ' ⚡ Mode: LIVE (data WILL be transferred)'
138
139
  );
139
140
  console.log('');
140
- console.log('='.repeat(60));
141
+ console.log('='.repeat(SEPARATOR_LENGTH));
141
142
  }
142
143
 
143
144
  export async function askConfirmation(config: Config): Promise<boolean> {
@@ -159,11 +160,12 @@ export function printSummary(
159
160
  stats: Stats,
160
161
  duration: string,
161
162
  logFile?: string,
162
- dryRun?: boolean
163
+ dryRun?: boolean,
164
+ verifyIntegrity?: boolean
163
165
  ): void {
164
- console.log('\n' + '='.repeat(60));
166
+ console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
165
167
  console.log('📊 TRANSFER SUMMARY');
166
- console.log('='.repeat(60));
168
+ console.log('='.repeat(SEPARATOR_LENGTH));
167
169
  console.log(`Collections processed: ${stats.collectionsProcessed}`);
168
170
  if (stats.documentsDeleted > 0) {
169
171
  console.log(`Documents deleted: ${stats.documentsDeleted}`);
@@ -172,8 +174,12 @@ export function printSummary(
172
174
  if (stats.conflicts > 0) {
173
175
  console.log(`Conflicts detected: ${stats.conflicts}`);
174
176
  }
175
- if (stats.integrityErrors > 0) {
176
- console.log(`Integrity errors: ${stats.integrityErrors}`);
177
+ if (verifyIntegrity) {
178
+ if (stats.integrityErrors > 0) {
179
+ console.log(`Integrity errors: ${stats.integrityErrors}`);
180
+ } else {
181
+ console.log(`Integrity verified: ✓ ${stats.documentsTransferred} documents`);
182
+ }
177
183
  }
178
184
  console.log(`Errors: ${stats.errors}`);
179
185
  console.log(`Duration: ${duration}s`);
@@ -188,7 +194,7 @@ export function printSummary(
188
194
  } else {
189
195
  console.log('\n✓ Transfer completed successfully');
190
196
  }
191
- console.log('='.repeat(60) + '\n');
197
+ console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
192
198
  }
193
199
 
194
200
  export function formatJsonOutput(
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import type { Config, TransferState, Stats } from '../types.js';
2
+ import { STATE_SAVE_INTERVAL_MS, STATE_SAVE_BATCH_INTERVAL } from '../constants.js';
3
+ import type { Config, ValidatedConfig, TransferState, Stats } from '../types.js';
3
4
 
4
5
  export const STATE_VERSION = 1;
5
6
 
@@ -81,15 +82,12 @@ export class CompletedDocsCache {
81
82
  // =============================================================================
82
83
 
83
84
  export interface StateSaverOptions {
84
- /** Save every N batches (default: 10) */
85
+ /** Save every N batches (default: STATE_SAVE_BATCH_INTERVAL) */
85
86
  batchInterval?: number;
86
- /** Save every N milliseconds (default: 5000) */
87
+ /** Save every N milliseconds (default: STATE_SAVE_INTERVAL_MS) */
87
88
  timeInterval?: number;
88
89
  }
89
90
 
90
- const DEFAULT_BATCH_INTERVAL = 10;
91
- const DEFAULT_TIME_INTERVAL = 5000;
92
-
93
91
  /**
94
92
  * Throttled state saver with O(1) completed doc lookups.
95
93
  * Uses CompletedDocsCache for efficient lookups during transfer.
@@ -108,8 +106,8 @@ export class StateSaver {
108
106
  private readonly state: TransferState,
109
107
  options: StateSaverOptions = {}
110
108
  ) {
111
- this.batchInterval = options.batchInterval ?? DEFAULT_BATCH_INTERVAL;
112
- this.timeInterval = options.timeInterval ?? DEFAULT_TIME_INTERVAL;
109
+ this.batchInterval = options.batchInterval ?? STATE_SAVE_BATCH_INTERVAL;
110
+ this.timeInterval = options.timeInterval ?? STATE_SAVE_INTERVAL_MS;
113
111
  this.cache = new CompletedDocsCache(state.completedDocs);
114
112
  }
115
113
 
@@ -244,11 +242,11 @@ export function deleteTransferState(stateFile: string): void {
244
242
  }
245
243
  }
246
244
 
247
- export function createInitialState(config: Config): TransferState {
245
+ export function createInitialState(config: ValidatedConfig): TransferState {
248
246
  return {
249
247
  version: STATE_VERSION,
250
- sourceProject: config.sourceProject!,
251
- destProject: config.destProject!,
248
+ sourceProject: config.sourceProject,
249
+ destProject: config.destProject,
252
250
  collections: config.collections,
253
251
  startedAt: new Date().toISOString(),
254
252
  updatedAt: new Date().toISOString(),
@@ -145,13 +145,14 @@ async function deleteOrphanBatch(
145
145
  return deletedCount;
146
146
  }
147
147
 
148
- async function processSubcollectionOrphans(
148
+ async function processSubcollectionOrphansWithProgress(
149
149
  sourceDb: Firestore,
150
150
  destDb: Firestore,
151
151
  sourceSnapshot: FirebaseFirestore.QuerySnapshot,
152
152
  sourceCollectionPath: string,
153
153
  config: Config,
154
- output: Output
154
+ output: Output,
155
+ progress?: DeleteOrphansProgress
155
156
  ): Promise<number> {
156
157
  let deletedCount = 0;
157
158
 
@@ -161,28 +162,48 @@ async function processSubcollectionOrphans(
161
162
  if (matchesExcludePattern(subId, config.exclude)) continue;
162
163
 
163
164
  const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
164
- deletedCount += await deleteOrphanDocuments(sourceDb, destDb, subPath, config, output);
165
+ progress?.onSubcollectionScan?.(subPath);
166
+ deletedCount += await deleteOrphanDocuments(
167
+ sourceDb,
168
+ destDb,
169
+ subPath,
170
+ config,
171
+ output,
172
+ progress
173
+ );
165
174
  }
166
175
  }
167
176
 
168
177
  return deletedCount;
169
178
  }
170
179
 
180
+ export interface DeleteOrphansProgress {
181
+ onScanStart?: (collection: string) => void;
182
+ onScanComplete?: (collection: string, orphanCount: number, totalDest: number) => void;
183
+ onBatchDeleted?: (collection: string, deletedSoFar: number, total: number) => void;
184
+ onSubcollectionScan?: (path: string) => void;
185
+ }
186
+
171
187
  export async function deleteOrphanDocuments(
172
188
  sourceDb: Firestore,
173
189
  destDb: Firestore,
174
190
  sourceCollectionPath: string,
175
191
  config: Config,
176
- output: Output
192
+ output: Output,
193
+ progress?: DeleteOrphansProgress
177
194
  ): Promise<number> {
178
195
  const destCollectionPath = getDestCollectionPath(sourceCollectionPath, config.renameCollection);
179
196
 
197
+ progress?.onScanStart?.(destCollectionPath);
198
+
180
199
  const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).select().get();
181
200
  const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
182
201
 
183
202
  const destSnapshot = await destDb.collection(destCollectionPath).select().get();
184
203
  const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
185
204
 
205
+ progress?.onScanComplete?.(destCollectionPath, orphanDocs.length, destSnapshot.size);
206
+
186
207
  let deletedCount = 0;
187
208
 
188
209
  if (orphanDocs.length > 0) {
@@ -197,17 +218,19 @@ export async function deleteOrphanDocuments(
197
218
  config,
198
219
  output
199
220
  );
221
+ progress?.onBatchDeleted?.(destCollectionPath, deletedCount, orphanDocs.length);
200
222
  }
201
223
  }
202
224
 
203
225
  if (config.includeSubcollections) {
204
- deletedCount += await processSubcollectionOrphans(
226
+ deletedCount += await processSubcollectionOrphansWithProgress(
205
227
  sourceDb,
206
228
  destDb,
207
229
  sourceSnapshot,
208
230
  sourceCollectionPath,
209
231
  config,
210
- output
232
+ output,
233
+ progress
211
234
  );
212
235
  }
213
236
 
@@ -31,6 +31,11 @@ async function countWithSubcollections(
31
31
  depth: number,
32
32
  progress?: CountProgress
33
33
  ): Promise<number> {
34
+ // Apply limit at root level only
35
+ if (depth === 0 && config.limit > 0) {
36
+ query = query.limit(config.limit);
37
+ }
38
+
34
39
  const snapshot = await query.select().get();
35
40
  let count = snapshot.size;
36
41
 
@@ -80,11 +85,17 @@ async function countSubcollectionsForDoc(
80
85
  async function countWithoutSubcollections(
81
86
  query: Query,
82
87
  collectionPath: string,
88
+ config: Config,
83
89
  depth: number,
84
90
  progress?: CountProgress
85
91
  ): Promise<number> {
86
92
  const countSnapshot = await query.count().get();
87
- const count = countSnapshot.data().count;
93
+ let count = countSnapshot.data().count;
94
+
95
+ // Apply limit at root level only
96
+ if (depth === 0 && config.limit > 0) {
97
+ count = Math.min(count, config.limit);
98
+ }
88
99
 
89
100
  if (depth === 0 && progress?.onCollection) {
90
101
  progress.onCollection(collectionPath, count);
@@ -106,5 +117,5 @@ export async function countDocuments(
106
117
  return countWithSubcollections(sourceDb, query, collectionPath, config, depth, progress);
107
118
  }
108
119
 
109
- return countWithoutSubcollections(query, collectionPath, depth, progress);
120
+ return countWithoutSubcollections(query, collectionPath, config, depth, progress);
110
121
  }
@@ -1,5 +1,5 @@
1
1
  export { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
2
2
  export { processInParallel, type ParallelResult } from './parallel.js';
3
3
  export { countDocuments, type CountProgress } from './count.js';
4
- export { clearCollection, deleteOrphanDocuments } from './clear.js';
4
+ export { clearCollection, deleteOrphanDocuments, type DeleteOrphansProgress } from './clear.js';
5
5
  export { transferCollection, type TransferContext } from './transfer.js';
@@ -186,6 +186,9 @@ function checkDocumentSize(
186
186
  );
187
187
  }
188
188
 
189
+ // Track which collections have already shown the max-depth warning (to avoid spam)
190
+ const maxDepthWarningsShown = new Set<string>();
191
+
189
192
  async function processSubcollections(
190
193
  ctx: TransferContext,
191
194
  doc: QueryDocumentSnapshot,
@@ -196,6 +199,15 @@ async function processSubcollections(
196
199
 
197
200
  // Check max depth limit (0 = unlimited)
198
201
  if (config.maxDepth > 0 && depth >= config.maxDepth) {
202
+ // Show console warning only once per root collection
203
+ const rootCollection = collectionPath.split('/')[0];
204
+ if (!maxDepthWarningsShown.has(rootCollection)) {
205
+ maxDepthWarningsShown.add(rootCollection);
206
+ output.warn(
207
+ `⚠️ Subcollections in ${rootCollection} beyond depth ${config.maxDepth} will be skipped`
208
+ );
209
+ }
210
+
199
211
  output.logInfo(`Skipping subcollections at depth ${depth} (max: ${config.maxDepth})`, {
200
212
  collection: collectionPath,
201
213
  docId: doc.id,
package/src/types.ts CHANGED
@@ -40,6 +40,12 @@ export interface Config {
40
40
  verifyIntegrity: boolean;
41
41
  }
42
42
 
43
+ // Config after validation - required fields are guaranteed non-null
44
+ export interface ValidatedConfig extends Omit<Config, 'sourceProject' | 'destProject'> {
45
+ sourceProject: string;
46
+ destProject: string;
47
+ }
48
+
43
49
  export interface ConflictInfo {
44
50
  collection: string;
45
51
  docId: string;
@@ -1,3 +1,10 @@
1
+ import {
2
+ isTimestamp,
3
+ isGeoPoint,
4
+ isDocumentReference,
5
+ getDocumentReferencePath,
6
+ } from './firestore-types.js';
7
+
1
8
  /**
2
9
  * Firestore maximum document size in bytes (1 MiB)
3
10
  */
@@ -31,24 +38,11 @@ export function estimateDocumentSize(data: Record<string, unknown>, docPath?: st
31
38
  return size;
32
39
  }
33
40
 
34
- function isFirestoreTimestamp(value: object): boolean {
35
- return '_seconds' in value && '_nanoseconds' in value;
36
- }
37
-
38
- function isGeoPoint(value: object): boolean {
39
- return '_latitude' in value && '_longitude' in value;
40
- }
41
-
42
- function isDocumentReference(value: object): boolean {
43
- return '_path' in value && typeof (value as { _path: unknown })._path === 'object';
44
- }
45
-
46
- function getDocRefSize(value: object): number {
47
- const pathObj = (value as { _path: { segments?: string[] } })._path;
48
- if (pathObj.segments) {
49
- return pathObj.segments.join('/').length + 1;
41
+ function getDocRefSize(value: unknown): number {
42
+ if (isDocumentReference(value)) {
43
+ return getDocumentReferencePath(value).length + 1;
50
44
  }
51
- return 16; // Approximate
45
+ return 16; // Approximate for unknown reference format
52
46
  }
53
47
 
54
48
  function estimateArraySize(arr: unknown[]): number {
@@ -76,7 +70,7 @@ function estimateValueSize(value: unknown): number {
76
70
  if (value instanceof Date) return 8;
77
71
 
78
72
  if (typeof value === 'object') {
79
- if (isFirestoreTimestamp(value)) return 8;
73
+ if (isTimestamp(value)) return 8;
80
74
  if (isGeoPoint(value)) return 16;
81
75
  if (isDocumentReference(value)) return getDocRefSize(value);
82
76
  if (Array.isArray(value)) return estimateArraySize(value);
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Rotate a file if it exceeds maxSize.
6
+ * Creates numbered backups: file.1.ext, file.2.ext, etc.
7
+ *
8
+ * @param filePath - Path to the file to rotate
9
+ * @param maxSize - Maximum file size in bytes (0 = no rotation)
10
+ * @param maxFiles - Maximum number of rotated files to keep (default: 5)
11
+ * @returns true if rotation occurred, false otherwise
12
+ */
13
+ export function rotateFileIfNeeded(
14
+ filePath: string,
15
+ maxSize: number,
16
+ maxFiles: number = 5
17
+ ): boolean {
18
+ if (!filePath || maxSize <= 0) return false;
19
+ if (!fs.existsSync(filePath)) return false;
20
+
21
+ const stats = fs.statSync(filePath);
22
+ if (stats.size < maxSize) return false;
23
+
24
+ const dir = path.dirname(filePath);
25
+ const ext = path.extname(filePath);
26
+ const base = path.basename(filePath, ext);
27
+
28
+ // Delete oldest backup if at max
29
+ const oldestPath = path.join(dir, `${base}.${maxFiles}${ext}`);
30
+ if (fs.existsSync(oldestPath)) {
31
+ fs.unlinkSync(oldestPath);
32
+ }
33
+
34
+ // Shift existing backups: .4 -> .5, .3 -> .4, etc.
35
+ for (let i = maxFiles - 1; i >= 1; i--) {
36
+ const from = path.join(dir, `${base}.${i}${ext}`);
37
+ const to = path.join(dir, `${base}.${i + 1}${ext}`);
38
+ if (fs.existsSync(from)) {
39
+ fs.renameSync(from, to);
40
+ }
41
+ }
42
+
43
+ // Rename current to .1
44
+ const backupPath = path.join(dir, `${base}.1${ext}`);
45
+ fs.renameSync(filePath, backupPath);
46
+
47
+ return true;
48
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Type guards for Firestore special types.
3
+ *
4
+ * Firestore SDK exposes these types with public properties:
5
+ * - Timestamp: { seconds: number, nanoseconds: number, toDate(), toMillis() }
6
+ * - GeoPoint: { latitude: number, longitude: number, isEqual() }
7
+ * - DocumentReference: { path: string, id: string, parent, ... }
8
+ *
9
+ * Note: Internal properties like _seconds, _path may exist but are not guaranteed.
10
+ * We use public API properties for reliability.
11
+ */
12
+
13
+ export interface FirestoreTimestamp {
14
+ seconds: number;
15
+ nanoseconds: number;
16
+ toDate?: () => Date;
17
+ toMillis?: () => number;
18
+ }
19
+
20
+ export interface FirestoreGeoPoint {
21
+ latitude: number;
22
+ longitude: number;
23
+ isEqual?: (other: unknown) => boolean;
24
+ }
25
+
26
+ export interface FirestoreDocumentReference {
27
+ path: string;
28
+ id: string;
29
+ }
30
+
31
+ /**
32
+ * Check if value is a Firestore Timestamp.
33
+ * Works with both SDK instances and plain objects with same shape.
34
+ */
35
+ export function isTimestamp(value: unknown): value is FirestoreTimestamp {
36
+ return (
37
+ typeof value === 'object' &&
38
+ value !== null &&
39
+ 'seconds' in value &&
40
+ 'nanoseconds' in value &&
41
+ typeof (value as FirestoreTimestamp).seconds === 'number' &&
42
+ typeof (value as FirestoreTimestamp).nanoseconds === 'number'
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Check if value is a Firestore GeoPoint.
48
+ * Excludes Timestamps which also have numeric properties.
49
+ */
50
+ export function isGeoPoint(value: unknown): value is FirestoreGeoPoint {
51
+ return (
52
+ typeof value === 'object' &&
53
+ value !== null &&
54
+ 'latitude' in value &&
55
+ 'longitude' in value &&
56
+ typeof (value as FirestoreGeoPoint).latitude === 'number' &&
57
+ typeof (value as FirestoreGeoPoint).longitude === 'number' &&
58
+ !('seconds' in value) // Distinguish from Timestamp
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Check if value is a Firestore DocumentReference.
64
+ */
65
+ export function isDocumentReference(value: unknown): value is FirestoreDocumentReference {
66
+ return (
67
+ typeof value === 'object' &&
68
+ value !== null &&
69
+ 'path' in value &&
70
+ 'id' in value &&
71
+ typeof (value as FirestoreDocumentReference).path === 'string' &&
72
+ typeof (value as FirestoreDocumentReference).id === 'string'
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Get the path from a DocumentReference.
78
+ * Handles both SDK instances and plain objects.
79
+ */
80
+ export function getDocumentReferencePath(ref: FirestoreDocumentReference): string {
81
+ return ref.path;
82
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { isTimestamp, isGeoPoint, isDocumentReference } from './firestore-types.js';
2
3
 
3
4
  /**
4
5
  * Compute a SHA-256 hash of document data.
@@ -28,17 +29,17 @@ function serializeForHash(value: unknown): string {
28
29
  }
29
30
 
30
31
  // Handle Firestore Timestamp
31
- if (isFirestoreTimestamp(value)) {
32
+ if (isTimestamp(value)) {
32
33
  return JSON.stringify({ _seconds: value.seconds, _nanoseconds: value.nanoseconds });
33
34
  }
34
35
 
35
36
  // Handle Firestore GeoPoint
36
- if (isFirestoreGeoPoint(value)) {
37
+ if (isGeoPoint(value)) {
37
38
  return JSON.stringify({ _latitude: value.latitude, _longitude: value.longitude });
38
39
  }
39
40
 
40
41
  // Handle Firestore DocumentReference
41
- if (isFirestoreDocumentReference(value)) {
42
+ if (isDocumentReference(value)) {
42
43
  return JSON.stringify({ _path: value.path });
43
44
  }
44
45
 
@@ -68,55 +69,3 @@ function serializeForHash(value: unknown): string {
68
69
  export function compareHashes(sourceHash: string, destHash: string): boolean {
69
70
  return sourceHash === destHash;
70
71
  }
71
-
72
- // Type guards for Firestore types
73
-
74
- interface FirestoreTimestamp {
75
- seconds: number;
76
- nanoseconds: number;
77
- toDate?: () => Date;
78
- }
79
-
80
- interface FirestoreGeoPoint {
81
- latitude: number;
82
- longitude: number;
83
- }
84
-
85
- interface FirestoreDocumentReference {
86
- path: string;
87
- id: string;
88
- }
89
-
90
- function isFirestoreTimestamp(value: unknown): value is FirestoreTimestamp {
91
- return (
92
- typeof value === 'object' &&
93
- value !== null &&
94
- 'seconds' in value &&
95
- 'nanoseconds' in value &&
96
- typeof (value as FirestoreTimestamp).seconds === 'number' &&
97
- typeof (value as FirestoreTimestamp).nanoseconds === 'number'
98
- );
99
- }
100
-
101
- function isFirestoreGeoPoint(value: unknown): value is FirestoreGeoPoint {
102
- return (
103
- typeof value === 'object' &&
104
- value !== null &&
105
- 'latitude' in value &&
106
- 'longitude' in value &&
107
- typeof (value as FirestoreGeoPoint).latitude === 'number' &&
108
- typeof (value as FirestoreGeoPoint).longitude === 'number' &&
109
- !('seconds' in value)
110
- );
111
- }
112
-
113
- function isFirestoreDocumentReference(value: unknown): value is FirestoreDocumentReference {
114
- return (
115
- typeof value === 'object' &&
116
- value !== null &&
117
- 'path' in value &&
118
- 'id' in value &&
119
- typeof (value as FirestoreDocumentReference).path === 'string' &&
120
- typeof (value as FirestoreDocumentReference).id === 'string'
121
- );
122
- }
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import type { Stats, LogEntry } from '../types.js';
3
+ import { rotateFileIfNeeded } from './file-rotation.js';
4
4
 
5
5
  export interface LoggerOptions {
6
6
  logPath?: string;
@@ -69,35 +69,9 @@ export class Logger {
69
69
  * Creates numbered backups: log.1, log.2, etc.
70
70
  */
71
71
  private rotateIfNeeded(): void {
72
- if (!this.logPath || this.maxSize <= 0) return;
73
- if (!fs.existsSync(this.logPath)) return;
74
-
75
- const stats = fs.statSync(this.logPath);
76
- if (stats.size < this.maxSize) return;
77
-
78
- // Rotate existing backups
79
- const dir = path.dirname(this.logPath);
80
- const ext = path.extname(this.logPath);
81
- const base = path.basename(this.logPath, ext);
82
-
83
- // Delete oldest if at max
84
- const oldestPath = path.join(dir, `${base}.${this.maxFiles}${ext}`);
85
- if (fs.existsSync(oldestPath)) {
86
- fs.unlinkSync(oldestPath);
87
- }
88
-
89
- // Shift existing backups: .4 -> .5, .3 -> .4, etc.
90
- for (let i = this.maxFiles - 1; i >= 1; i--) {
91
- const from = path.join(dir, `${base}.${i}${ext}`);
92
- const to = path.join(dir, `${base}.${i + 1}${ext}`);
93
- if (fs.existsSync(from)) {
94
- fs.renameSync(from, to);
95
- }
72
+ if (this.logPath) {
73
+ rotateFileIfNeeded(this.logPath, this.maxSize, this.maxFiles);
96
74
  }
97
-
98
- // Rename current to .1
99
- const backupPath = path.join(dir, `${base}.1${ext}`);
100
- fs.renameSync(this.logPath, backupPath);
101
75
  }
102
76
 
103
77
  summary(stats: Stats, duration: string): void {
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
2
+ import { SEPARATOR_LENGTH } from '../constants.js';
3
3
  import type { Stats, LogEntry } from '../types.js';
4
+ import { rotateFileIfNeeded } from './file-rotation.js';
4
5
 
5
6
  /**
6
7
  * Parse a size string like "10MB" or "1GB" into bytes.
@@ -72,38 +73,13 @@ export class Output {
72
73
  * Creates numbered backups: log.1.ext, log.2.ext, etc.
73
74
  */
74
75
  private rotateLogIfNeeded(): void {
75
- const logFile = this.options.logFile;
76
- const maxSize = this.options.maxLogSize ?? 0;
77
- const maxFiles = this.options.maxLogFiles ?? 5;
78
-
79
- if (!logFile || maxSize <= 0) return;
80
- if (!fs.existsSync(logFile)) return;
81
-
82
- const stats = fs.statSync(logFile);
83
- if (stats.size < maxSize) return;
84
-
85
- const dir = path.dirname(logFile);
86
- const ext = path.extname(logFile);
87
- const base = path.basename(logFile, ext);
88
-
89
- // Delete oldest backup if at max
90
- const oldestPath = path.join(dir, `${base}.${maxFiles}${ext}`);
91
- if (fs.existsSync(oldestPath)) {
92
- fs.unlinkSync(oldestPath);
93
- }
94
-
95
- // Shift existing backups: .4 -> .5, .3 -> .4, etc.
96
- for (let i = maxFiles - 1; i >= 1; i--) {
97
- const from = path.join(dir, `${base}.${i}${ext}`);
98
- const to = path.join(dir, `${base}.${i + 1}${ext}`);
99
- if (fs.existsSync(from)) {
100
- fs.renameSync(from, to);
101
- }
76
+ if (this.options.logFile) {
77
+ rotateFileIfNeeded(
78
+ this.options.logFile,
79
+ this.options.maxLogSize ?? 0,
80
+ this.options.maxLogFiles ?? 5
81
+ );
102
82
  }
103
-
104
- // Rename current to .1
105
- const backupPath = path.join(dir, `${base}.1${ext}`);
106
- fs.renameSync(logFile, backupPath);
107
83
  }
108
84
 
109
85
  // ==========================================================================
@@ -156,7 +132,7 @@ export class Output {
156
132
  }
157
133
 
158
134
  /** Print a separator line */
159
- separator(char: string = '=', length: number = 60): void {
135
+ separator(char: string = '=', length: number = SEPARATOR_LENGTH): void {
160
136
  if (!this.options.quiet && !this.options.json) {
161
137
  console.log(char.repeat(length));
162
138
  }
@@ -165,9 +141,9 @@ export class Output {
165
141
  /** Print a header with separators */
166
142
  header(title: string): void {
167
143
  if (!this.options.quiet && !this.options.json) {
168
- console.log('='.repeat(60));
144
+ console.log('='.repeat(SEPARATOR_LENGTH));
169
145
  console.log(title);
170
- console.log('='.repeat(60));
146
+ console.log('='.repeat(SEPARATOR_LENGTH));
171
147
  }
172
148
  }
173
149
 
@@ -1,4 +1,5 @@
1
1
  import cliProgress from 'cli-progress';
2
+ import { SPEED_UPDATE_INTERVAL_MS, PROGRESS_FLUSH_INTERVAL_MS } from '../constants.js';
2
3
  import type { Stats } from '../types.js';
3
4
 
4
5
  export interface ProgressBarOptions {
@@ -17,14 +18,18 @@ const DEFAULT_OPTIONS: ProgressBarOptions = {
17
18
 
18
19
  /**
19
20
  * Wrapper around cli-progress that handles speed calculation and cleanup.
20
- * Eliminates the need for type hacks to store the speed interval.
21
+ * Thread-safe for parallel mode: uses batched updates to prevent UI flickering.
21
22
  */
22
23
  export class ProgressBarWrapper {
23
24
  private bar: cliProgress.SingleBar | null = null;
24
25
  private speedInterval: NodeJS.Timeout | null = null;
26
+ private flushInterval: NodeJS.Timeout | null = null;
25
27
  private lastDocsTransferred = 0;
26
28
  private lastTime = Date.now();
27
29
 
30
+ // Batched increment counter for parallel-safe updates
31
+ private pendingIncrements = 0;
32
+
28
33
  constructor(private readonly options: ProgressBarOptions = {}) {}
29
34
 
30
35
  /**
@@ -45,21 +50,50 @@ export class ProgressBarWrapper {
45
50
  this.bar.start(total, 0, { speed: '0' });
46
51
  this.lastDocsTransferred = 0;
47
52
  this.lastTime = Date.now();
53
+ this.pendingIncrements = 0;
48
54
 
55
+ // Speed update interval
49
56
  this.speedInterval = setInterval(() => {
50
57
  this.updateSpeed(stats);
51
- }, 500);
58
+ }, SPEED_UPDATE_INTERVAL_MS);
59
+
60
+ // Batched increment flush interval (for parallel mode)
61
+ this.flushInterval = setInterval(() => {
62
+ this.flushIncrements();
63
+ }, PROGRESS_FLUSH_INTERVAL_MS);
52
64
  }
53
65
 
54
66
  /**
55
67
  * Increment the progress bar by 1.
68
+ * Thread-safe: increments are batched and flushed periodically.
56
69
  */
57
70
  increment(): void {
58
71
  if (this.bar) {
59
- this.bar.increment();
72
+ this.pendingIncrements++;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Increment the progress bar by a specific amount.
78
+ * Thread-safe: increments are batched and flushed periodically.
79
+ */
80
+ incrementBy(count: number): void {
81
+ if (this.bar && count > 0) {
82
+ this.pendingIncrements += count;
60
83
  }
61
84
  }
62
85
 
86
+ /**
87
+ * Flush pending increments to the progress bar.
88
+ */
89
+ private flushIncrements(): void {
90
+ if (!this.bar || this.pendingIncrements === 0) return;
91
+
92
+ const toFlush = this.pendingIncrements;
93
+ this.pendingIncrements = 0;
94
+ this.bar.increment(toFlush);
95
+ }
96
+
63
97
  /**
64
98
  * Update the speed display based on current stats.
65
99
  */
@@ -80,9 +114,17 @@ export class ProgressBarWrapper {
80
114
  }
81
115
 
82
116
  /**
83
- * Stop the progress bar and clean up the speed interval.
117
+ * Stop the progress bar and clean up intervals.
118
+ * Flushes any pending increments before stopping.
84
119
  */
85
120
  stop(): void {
121
+ // Flush remaining increments
122
+ this.flushIncrements();
123
+
124
+ if (this.flushInterval) {
125
+ clearInterval(this.flushInterval);
126
+ this.flushInterval = null;
127
+ }
86
128
  if (this.speedInterval) {
87
129
  clearInterval(this.speedInterval);
88
130
  this.speedInterval = null;
@@ -125,22 +125,62 @@ export async function sendWebhook(
125
125
  }
126
126
 
127
127
  try {
128
+ const controller = new AbortController();
129
+ const timeout = setTimeout(() => controller.abort(), 30000);
130
+
128
131
  const response = await fetch(webhookUrl, {
129
132
  method: 'POST',
130
133
  headers: { 'Content-Type': 'application/json' },
131
134
  body: JSON.stringify(body),
135
+ signal: controller.signal,
132
136
  });
133
137
 
138
+ clearTimeout(timeout);
139
+
134
140
  if (!response.ok) {
135
141
  const errorText = await response.text();
136
- throw new Error(`HTTP ${response.status}: ${errorText}`);
142
+ const statusCode = response.status;
143
+
144
+ if (statusCode >= 400 && statusCode < 500) {
145
+ // Client error - likely bad URL or payload format
146
+ output.logError(`Webhook client error (${statusCode})`, {
147
+ url: webhookUrl,
148
+ status: statusCode,
149
+ error: errorText,
150
+ });
151
+ output.warn(
152
+ `⚠️ Webhook failed (HTTP ${statusCode}): Check webhook URL or payload format`
153
+ );
154
+ } else if (statusCode >= 500) {
155
+ // Server error - retry might help
156
+ output.logError(`Webhook server error (${statusCode})`, {
157
+ url: webhookUrl,
158
+ status: statusCode,
159
+ error: errorText,
160
+ });
161
+ output.warn(
162
+ `⚠️ Webhook server error (HTTP ${statusCode}): The webhook service may be temporarily unavailable`
163
+ );
164
+ }
165
+ return;
137
166
  }
138
167
 
139
168
  output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
140
169
  output.info(`📤 Webhook notification sent (${webhookType})`);
141
170
  } catch (error) {
142
- const message = error instanceof Error ? error.message : String(error);
143
- output.logError(`Failed to send webhook: ${message}`, { url: webhookUrl });
144
- output.warn(`⚠️ Failed to send webhook: ${message}`);
171
+ const err = error as Error;
172
+
173
+ if (err.name === 'AbortError') {
174
+ output.logError('Webhook timeout after 30s', { url: webhookUrl });
175
+ output.warn('⚠️ Webhook request timed out after 30 seconds');
176
+ } else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
177
+ output.logError(`Webhook connection failed: ${err.message}`, { url: webhookUrl });
178
+ output.warn(
179
+ `⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
180
+ );
181
+ } else {
182
+ output.logError(`Failed to send webhook: ${err.message}`, { url: webhookUrl });
183
+ output.warn(`⚠️ Failed to send webhook: ${err.message}`);
184
+ }
145
185
  }
146
186
  }