@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.
@@ -1,4 +1,6 @@
1
- import type { DocumentReference } from 'firebase-admin/firestore';
1
+ import type { DocumentReference, Firestore, Query } from 'firebase-admin/firestore';
2
+ import type { Config } from '../types.js';
3
+ import { matchesExcludePattern } from '../utils/patterns.js';
2
4
 
3
5
  export async function getSubcollections(docRef: DocumentReference): Promise<string[]> {
4
6
  const collections = await docRef.listCollections();
@@ -35,3 +37,36 @@ export function getDestDocId(
35
37
  }
36
38
  return destId;
37
39
  }
40
+
41
+ /**
42
+ * Get non-excluded subcollection IDs for a document.
43
+ * Filters out subcollections matching exclude patterns.
44
+ */
45
+ export async function getFilteredSubcollections(
46
+ docRef: DocumentReference,
47
+ exclude: string[]
48
+ ): Promise<string[]> {
49
+ const subcollections = await getSubcollections(docRef);
50
+ return subcollections.filter((id) => !matchesExcludePattern(id, exclude));
51
+ }
52
+
53
+ /**
54
+ * Build a Firestore query with where filters applied.
55
+ * Filters are only applied at root level (depth === 0).
56
+ */
57
+ export function buildQueryWithFilters(
58
+ sourceDb: Firestore,
59
+ collectionPath: string,
60
+ config: Config,
61
+ depth: number
62
+ ): Query {
63
+ let query: Query = sourceDb.collection(collectionPath);
64
+
65
+ if (depth === 0 && config.where.length > 0) {
66
+ for (const filter of config.where) {
67
+ query = query.where(filter.field, filter.operator, filter.value);
68
+ }
69
+ }
70
+
71
+ return query;
72
+ }
@@ -1,4 +1,10 @@
1
- export { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
1
+ export {
2
+ getSubcollections,
3
+ getDestCollectionPath,
4
+ getDestDocId,
5
+ getFilteredSubcollections,
6
+ buildQueryWithFilters,
7
+ } from './helpers.js';
2
8
  export { processInParallel, type ParallelResult } from './parallel.js';
3
9
  export { countDocuments, type CountProgress } from './count.js';
4
10
  export { clearCollection, deleteOrphanDocuments, type DeleteOrphansProgress } from './clear.js';
@@ -1,14 +1,23 @@
1
- import type { Firestore, WriteBatch, Query, QueryDocumentSnapshot } from 'firebase-admin/firestore';
1
+ import {
2
+ FieldPath,
3
+ type Firestore,
4
+ type WriteBatch,
5
+ type QueryDocumentSnapshot,
6
+ } from 'firebase-admin/firestore';
2
7
  import type { Config, Stats, TransformFunction, ConflictInfo } from '../types.js';
3
8
  import type { Output } from '../utils/output.js';
4
9
  import type { RateLimiter } from '../utils/rate-limiter.js';
5
10
  import type { ProgressBarWrapper } from '../utils/progress.js';
6
11
  import type { StateSaver } from '../state/index.js';
7
12
  import { withRetry } from '../utils/retry.js';
8
- import { matchesExcludePattern } from '../utils/patterns.js';
9
13
  import { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from '../utils/doc-size.js';
10
14
  import { hashDocumentData, compareHashes } from '../utils/integrity.js';
11
- import { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
15
+ import {
16
+ getDestCollectionPath,
17
+ getDestDocId,
18
+ getFilteredSubcollections,
19
+ buildQueryWithFilters,
20
+ } from './helpers.js';
12
21
 
13
22
  export interface TransferContext {
14
23
  sourceDb: Firestore;
@@ -21,6 +30,7 @@ export interface TransferContext {
21
30
  stateSaver: StateSaver | null;
22
31
  rateLimiter: RateLimiter | null;
23
32
  conflictList: ConflictInfo[];
33
+ maxDepthWarningsShown: Set<string>;
24
34
  }
25
35
 
26
36
  interface DocProcessResult {
@@ -29,96 +39,6 @@ interface DocProcessResult {
29
39
  markCompleted: boolean;
30
40
  }
31
41
 
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);
49
-
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 buildBaseQuery(
105
- sourceDb: Firestore,
106
- collectionPath: string,
107
- config: Config,
108
- depth: number
109
- ): Query {
110
- let query: Query = sourceDb.collection(collectionPath);
111
-
112
- if (depth === 0 && config.where.length > 0) {
113
- for (const filter of config.where) {
114
- query = query.where(filter.field, filter.operator, filter.value);
115
- }
116
- }
117
-
118
- // Limit is handled via pagination in transferCollection
119
- return query;
120
- }
121
-
122
42
  function applyTransform(
123
43
  docData: Record<string, unknown>,
124
44
  doc: QueryDocumentSnapshot,
@@ -149,6 +69,7 @@ function applyTransform(
149
69
  collection: collectionPath,
150
70
  error: errMsg,
151
71
  });
72
+ output.warn(`⚠️ Transform error: ${collectionPath}/${doc.id} skipped (${errMsg})`);
152
73
  stats.errors++;
153
74
  return { success: false, data: null, markCompleted: false };
154
75
  }
@@ -183,9 +104,6 @@ function checkDocumentSize(
183
104
  );
184
105
  }
185
106
 
186
- // Track which collections have already shown the max-depth warning (to avoid spam)
187
- const maxDepthWarningsShown = new Set<string>();
188
-
189
107
  async function processSubcollections(
190
108
  ctx: TransferContext,
191
109
  doc: QueryDocumentSnapshot,
@@ -198,8 +116,8 @@ async function processSubcollections(
198
116
  if (config.maxDepth > 0 && depth >= config.maxDepth) {
199
117
  // Show console warning only once per root collection
200
118
  const rootCollection = collectionPath.split('/')[0];
201
- if (!maxDepthWarningsShown.has(rootCollection)) {
202
- maxDepthWarningsShown.add(rootCollection);
119
+ if (!ctx.maxDepthWarningsShown.has(rootCollection)) {
120
+ ctx.maxDepthWarningsShown.add(rootCollection);
203
121
  output.warn(
204
122
  `⚠️ Subcollections in ${rootCollection} beyond depth ${config.maxDepth} will be skipped`
205
123
  );
@@ -212,15 +130,21 @@ async function processSubcollections(
212
130
  return;
213
131
  }
214
132
 
215
- const subcollections = await getSubcollections(doc.ref);
133
+ const subcollections = await getFilteredSubcollections(doc.ref, config.exclude);
216
134
 
217
135
  for (const subcollectionId of subcollections) {
218
- if (matchesExcludePattern(subcollectionId, config.exclude)) {
219
- output.logInfo(`Skipping excluded subcollection: ${subcollectionId}`);
220
- continue;
136
+ const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
137
+
138
+ // Count subcollection docs with .count() aggregation (1 read instead of N)
139
+ // and dynamically adjust the progress bar total
140
+ if (ctx.progressBar.isActive) {
141
+ const countSnap = await ctx.sourceDb.collection(subcollectionPath).count().get();
142
+ const subCount = countSnap.data().count;
143
+ if (subCount > 0) {
144
+ ctx.progressBar.addToTotal(subCount);
145
+ }
221
146
  }
222
147
 
223
- const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
224
148
  const subCtx = { ...ctx, config: { ...config, limit: 0, where: [] } };
225
149
  await transferCollection(subCtx, subcollectionPath, depth + 1);
226
150
  }
@@ -241,7 +165,18 @@ function processDocument(
241
165
  }
242
166
 
243
167
  const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
244
- let docData = doc.data() as Record<string, unknown>;
168
+ let docData: Record<string, unknown>;
169
+ try {
170
+ docData = doc.data() as Record<string, unknown>;
171
+ } catch (error) {
172
+ const errMsg = error instanceof Error ? error.message : String(error);
173
+ output.logError(`Failed to read document data for ${doc.id}`, {
174
+ collection: collectionPath,
175
+ error: errMsg,
176
+ });
177
+ stats.errors++;
178
+ return { skip: true, markCompleted: false };
179
+ }
245
180
 
246
181
  // Apply transform if provided
247
182
  if (transformFn) {
@@ -292,12 +227,31 @@ async function commitBatchWithRetry(
292
227
  await rateLimiter.acquire(batchDocIds.length);
293
228
  }
294
229
 
295
- await withRetry(() => destBatch.commit(), {
296
- retries: config.retries,
297
- onRetry: (attempt, max, err, delay) => {
298
- output.logError(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
299
- },
300
- });
230
+ try {
231
+ await withRetry(() => destBatch.commit(), {
232
+ retries: config.retries,
233
+ onRetry: (attempt, max, err, delay) => {
234
+ output.logError(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
235
+ },
236
+ });
237
+ } catch (error) {
238
+ const err = error instanceof Error ? error : new Error(String(error));
239
+ stats.errors += batchDocIds.length;
240
+ output.logError(
241
+ `Batch commit failed for ${batchDocIds.length} documents after ${config.retries} retries`,
242
+ {
243
+ collection: collectionPath,
244
+ error: err.message,
245
+ docIds: batchDocIds.slice(0, 10),
246
+ }
247
+ );
248
+ output.warn(
249
+ `⚠️ Batch commit failed: ${batchDocIds.length} documents in ${collectionPath} were NOT written (${err.message})`
250
+ );
251
+ // Re-decrement documentsTransferred since they weren't actually committed
252
+ stats.documentsTransferred -= batchDocIds.length;
253
+ return;
254
+ }
301
255
 
302
256
  if (stateSaver && batchDocIds.length > 0) {
303
257
  stateSaver.markBatchCompleted(collectionPath, batchDocIds, stats);
@@ -362,9 +316,40 @@ async function verifyBatchIntegrity(
362
316
  preparedDocs: PreparedDoc[],
363
317
  destDb: Firestore,
364
318
  destCollectionPath: string,
319
+ merge: boolean,
365
320
  stats: Stats,
366
321
  output: Output
367
322
  ): Promise<void> {
323
+ if (!merge) {
324
+ // Non-merge mode: data written is exactly what we sent, no re-fetch needed.
325
+ // The source hash was computed from the same data we wrote, so they must match.
326
+ // We only need to verify the docs exist (spot-check a single doc for commit success).
327
+ const sampleRef = destDb.collection(destCollectionPath).doc(preparedDocs[0].destDocId);
328
+ const sampleDoc = await sampleRef.get();
329
+ if (!sampleDoc.exists) {
330
+ // Commit may have silently failed — verify all
331
+ const docRefs = preparedDocs.map((p) =>
332
+ destDb.collection(destCollectionPath).doc(p.destDocId)
333
+ );
334
+ const destDocs = await destDb.getAll(...docRefs);
335
+ for (let i = 0; i < destDocs.length; i++) {
336
+ if (!destDocs[i].exists) {
337
+ stats.integrityErrors++;
338
+ output.warn(
339
+ `⚠️ Integrity error: ${destCollectionPath}/${preparedDocs[i].destDocId} not found after write`
340
+ );
341
+ output.logError('Integrity verification failed', {
342
+ collection: destCollectionPath,
343
+ docId: preparedDocs[i].destDocId,
344
+ reason: 'document_not_found',
345
+ });
346
+ }
347
+ }
348
+ }
349
+ return;
350
+ }
351
+
352
+ // Merge mode: re-fetch and compare hashes (merged result may differ from source)
368
353
  const docRefs = preparedDocs.map((p) => destDb.collection(destCollectionPath).doc(p.destDocId));
369
354
  const destDocs = await destDb.getAll(...docRefs);
370
355
 
@@ -447,7 +432,14 @@ async function commitPreparedDocs(
447
432
 
448
433
  // Verify integrity after commit if enabled
449
434
  if (config.verifyIntegrity) {
450
- await verifyBatchIntegrity(preparedDocs, destDb, destCollectionPath, stats, output);
435
+ await verifyBatchIntegrity(
436
+ preparedDocs,
437
+ destDb,
438
+ destCollectionPath,
439
+ config.merge,
440
+ stats,
441
+ output
442
+ );
451
443
  }
452
444
  }
453
445
 
@@ -476,50 +468,50 @@ async function processBatch(
476
468
  return [];
477
469
  }
478
470
 
479
- // Step 2: If conflict detection is enabled, capture dest updateTimes and check for conflicts
480
- let docsToWrite = preparedDocs;
471
+ // Step 2: If conflict detection is enabled, check for existing docs in destination
472
+ // Uses chunked 'in' queries with .select() to minimize reads:
473
+ // - Firestore 'in' operator supports max 30 values per query
474
+ // - .select() avoids transferring field data (saves bandwidth)
475
+ // - Only existing docs cost reads; non-existent docs are free (unlike getAll)
481
476
  if (config.detectConflicts && !config.dryRun) {
482
477
  const destDocIds = preparedDocs.map((p) => p.destDocId);
483
- const capturedTimes = await captureDestUpdateTimes(destDb, destCollectionPath, destDocIds);
484
-
485
- // Check for conflicts
486
- const conflictingIds = await checkForConflicts(
487
- destDb,
488
- destCollectionPath,
489
- destDocIds,
490
- capturedTimes
491
- );
492
-
493
- if (conflictingIds.length > 0) {
494
- const conflictSet = new Set(conflictingIds);
495
-
496
- // Filter out conflicting docs
497
- docsToWrite = preparedDocs.filter((p) => !conflictSet.has(p.destDocId));
478
+ const existingIds = new Set<string>();
479
+ const FIRESTORE_IN_LIMIT = 30;
480
+
481
+ for (let i = 0; i < destDocIds.length; i += FIRESTORE_IN_LIMIT) {
482
+ const chunk = destDocIds.slice(i, i + FIRESTORE_IN_LIMIT);
483
+ const snapshot = await destDb
484
+ .collection(destCollectionPath)
485
+ .where(FieldPath.documentId(), 'in', chunk)
486
+ .select()
487
+ .get();
488
+ for (const doc of snapshot.docs) {
489
+ existingIds.add(doc.id);
490
+ }
491
+ }
498
492
 
499
- // Record conflicts
500
- for (const prepared of preparedDocs) {
501
- if (conflictSet.has(prepared.destDocId)) {
502
- stats.conflicts++;
503
- conflictList.push({
504
- collection: destCollectionPath,
505
- docId: prepared.destDocId,
506
- reason: 'Document was modified during transfer',
507
- });
508
- output.warn(
509
- `⚠️ Conflict detected: ${destCollectionPath}/${prepared.destDocId} was modified during transfer`
510
- );
511
- output.logError('Conflict detected', {
512
- collection: destCollectionPath,
513
- docId: prepared.destDocId,
514
- reason: 'modified_during_transfer',
515
- });
516
- }
493
+ if (existingIds.size > 0) {
494
+ for (const docId of existingIds) {
495
+ stats.conflicts++;
496
+ conflictList.push({
497
+ collection: destCollectionPath,
498
+ docId,
499
+ reason: 'Document already exists in destination',
500
+ });
501
+ output.logError('Conflict detected', {
502
+ collection: destCollectionPath,
503
+ docId,
504
+ reason: 'document_exists_in_destination',
505
+ });
517
506
  }
507
+ output.warn(
508
+ `⚠️ ${existingIds.size} document(s) already exist in ${destCollectionPath} and will be overwritten`
509
+ );
518
510
  }
519
511
  }
520
512
 
521
- // Step 3: Commit non-conflicting docs
522
- return commitPreparedDocs(docsToWrite, ctx, collectionPath, destCollectionPath, depth);
513
+ // Step 3: Commit docs
514
+ return commitPreparedDocs(preparedDocs, ctx, collectionPath, destCollectionPath, depth);
523
515
  }
524
516
 
525
517
  export async function transferCollection(
@@ -530,7 +522,7 @@ export async function transferCollection(
530
522
  const { sourceDb, config, stats, output } = ctx;
531
523
  const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
532
524
 
533
- const baseQuery = buildBaseQuery(sourceDb, collectionPath, config, depth);
525
+ const baseQuery = buildQueryWithFilters(sourceDb, collectionPath, config, depth);
534
526
  const userLimit = config.limit > 0 && depth === 0 ? config.limit : 0;
535
527
 
536
528
  let totalProcessed = 0;
@@ -2,9 +2,19 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type { TransformFunction } from '../types.js';
4
4
 
5
+ const ALLOWED_EXTENSIONS = new Set(['.ts', '.js', '.mjs', '.mts']);
6
+
5
7
  export async function loadTransformFunction(transformPath: string): Promise<TransformFunction> {
6
8
  const absolutePath = path.resolve(transformPath);
7
9
 
10
+ // Validate file extension
11
+ const ext = path.extname(absolutePath).toLowerCase();
12
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
13
+ throw new Error(
14
+ `Transform file must be a JavaScript or TypeScript file (${[...ALLOWED_EXTENSIONS].join(', ')}). Got: "${ext || '(no extension)'}"`
15
+ );
16
+ }
17
+
8
18
  if (!fs.existsSync(absolutePath)) {
9
19
  throw new Error(`Transform file not found: ${absolutePath}`);
10
20
  }
@@ -26,6 +36,8 @@ export async function loadTransformFunction(transformPath: string): Promise<Tran
26
36
  if ((error as Error).message.includes('Transform file')) {
27
37
  throw error;
28
38
  }
29
- throw new Error(`Failed to load transform file: ${(error as Error).message}`);
39
+ throw new Error(`Failed to load transform file: ${(error as Error).message}`, {
40
+ cause: error,
41
+ });
30
42
  }
31
43
  }
package/src/types.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  export interface WhereFilter {
6
6
  field: string;
7
7
  operator: FirebaseFirestore.WhereFilterOp;
8
- value: string | number | boolean;
8
+ value: string | number | boolean | null;
9
9
  }
10
10
 
11
11
  export interface Config {
@@ -38,6 +38,7 @@ export interface Config {
38
38
  detectConflicts: boolean;
39
39
  maxDepth: number;
40
40
  verifyIntegrity: boolean;
41
+ allowHttpWebhook: boolean;
41
42
  }
42
43
 
43
44
  // Config after validation - required fields are guaranteed non-null
@@ -121,5 +122,6 @@ export interface CliArgs {
121
122
  detectConflicts?: boolean;
122
123
  maxDepth?: number;
123
124
  verifyIntegrity?: boolean;
125
+ allowHttpWebhook?: boolean;
124
126
  validateOnly?: boolean;
125
127
  }
@@ -28,10 +28,13 @@ export function ensureCredentials(): void {
28
28
  const { exists, path: credPath } = checkCredentialsExist();
29
29
 
30
30
  if (!exists) {
31
- console.error('\n❌ Google Cloud credentials not found.');
32
- console.error(` Expected at: ${credPath}\n`);
33
- console.error(' Run this command to authenticate:');
34
- console.error(' gcloud auth application-default login\n');
31
+ const msg = [
32
+ '\n❌ Google Cloud credentials not found.',
33
+ ` Expected at: ${credPath}\n`,
34
+ ' Run this command to authenticate:',
35
+ ' gcloud auth application-default login\n',
36
+ ].join('\n');
37
+ process.stderr.write(msg);
35
38
  process.exit(1);
36
39
  }
37
40
  }
@@ -1,3 +1,8 @@
1
+ /** Error with optional Firebase/gRPC error code */
2
+ export interface FirebaseError extends Error {
3
+ code?: string;
4
+ }
5
+
1
6
  export interface FirebaseErrorInfo {
2
7
  message: string;
3
8
  suggestion?: string;
@@ -95,7 +100,7 @@ const errorMap: Record<string, FirebaseErrorInfo> = {
95
100
  },
96
101
  };
97
102
 
98
- export function formatFirebaseError(error: Error & { code?: string }): FirebaseErrorInfo {
103
+ export function formatFirebaseError(error: FirebaseError): FirebaseErrorInfo {
99
104
  // Check by error code first
100
105
  if (error.code) {
101
106
  const mapped = errorMap[error.code];
@@ -133,7 +138,7 @@ export function formatFirebaseError(error: Error & { code?: string }): FirebaseE
133
138
  }
134
139
 
135
140
  export function logFirebaseError(
136
- error: Error & { code?: string },
141
+ error: FirebaseError,
137
142
  context: string,
138
143
  logger?: { error: (msg: string, data?: Record<string, unknown>) => void }
139
144
  ): void {
@@ -3,6 +3,11 @@ export { Output, type OutputOptions } from './output.js';
3
3
  export { ProgressBarWrapper, type ProgressBarOptions } from './progress.js';
4
4
  export { checkCredentialsExist, ensureCredentials } from './credentials.js';
5
5
  export { matchesExcludePattern } from './patterns.js';
6
- export { formatFirebaseError, logFirebaseError, type FirebaseErrorInfo } from './errors.js';
6
+ export {
7
+ formatFirebaseError,
8
+ logFirebaseError,
9
+ type FirebaseError,
10
+ type FirebaseErrorInfo,
11
+ } from './errors.js';
7
12
  export { RateLimiter } from './rate-limiter.js';
8
13
  export { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from './doc-size.js';
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { SEPARATOR_LENGTH } from '../constants.js';
3
4
  import type { Stats, LogEntry } from '../types.js';
4
5
  import { rotateFileIfNeeded } from './file-rotation.js';
@@ -62,6 +63,11 @@ export class Output {
62
63
 
63
64
  init(): void {
64
65
  if (this.options.logFile) {
66
+ // Ensure parent directory exists
67
+ const dir = path.dirname(this.options.logFile);
68
+ if (!fs.existsSync(dir)) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ }
65
71
  this.rotateLogIfNeeded();
66
72
  const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
67
73
  fs.writeFileSync(this.options.logFile, header);
@@ -135,6 +135,16 @@ export class ProgressBarWrapper {
135
135
  }
136
136
  }
137
137
 
138
+ /**
139
+ * Increase the progress bar total by the given amount.
140
+ * Used for dynamically discovered work (e.g., subcollections).
141
+ */
142
+ addToTotal(count: number): void {
143
+ if (this.bar && count > 0) {
144
+ this.bar.setTotal(this.bar.getTotal() + count);
145
+ }
146
+ }
147
+
138
148
  /**
139
149
  * Check if the progress bar is active.
140
150
  */