@fazetitans/fscopy 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/package.json +19 -15
- package/src/cli.ts +10 -2
- package/src/config/defaults.ts +1 -0
- package/src/config/parser.ts +90 -53
- package/src/config/validator.ts +51 -0
- package/src/constants.ts +39 -0
- package/src/firebase/index.ts +29 -18
- package/src/interactive.ts +6 -1
- package/src/orchestrator.ts +69 -57
- package/src/state/index.ts +42 -26
- package/src/transfer/clear.ts +104 -68
- package/src/transfer/count.ts +44 -35
- package/src/transfer/helpers.ts +36 -1
- package/src/transfer/index.ts +7 -1
- package/src/transfer/transfer.ts +141 -149
- package/src/transform/loader.ts +13 -1
- package/src/types.ts +3 -1
- package/src/utils/credentials.ts +7 -4
- package/src/utils/errors.ts +7 -2
- package/src/utils/index.ts +6 -1
- package/src/utils/output.ts +6 -0
- package/src/utils/progress.ts +10 -0
- package/src/webhook/index.ts +127 -44
package/src/transfer/helpers.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/transfer/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
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';
|
package/src/transfer/transfer.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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
|
|
133
|
+
const subcollections = await getFilteredSubcollections(doc.ref, config.exclude);
|
|
216
134
|
|
|
217
135
|
for (const subcollectionId of subcollections) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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(
|
|
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,
|
|
480
|
-
|
|
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
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
500
|
-
for (const
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
|
522
|
-
return commitPreparedDocs(
|
|
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 =
|
|
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;
|
package/src/transform/loader.ts
CHANGED
|
@@ -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
|
}
|
package/src/utils/credentials.ts
CHANGED
|
@@ -28,10 +28,13 @@ export function ensureCredentials(): void {
|
|
|
28
28
|
const { exists, path: credPath } = checkCredentialsExist();
|
|
29
29
|
|
|
30
30
|
if (!exists) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
package/src/utils/errors.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
141
|
+
error: FirebaseError,
|
|
137
142
|
context: string,
|
|
138
143
|
logger?: { error: (msg: string, data?: Record<string, unknown>) => void }
|
|
139
144
|
): void {
|
package/src/utils/index.ts
CHANGED
|
@@ -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 {
|
|
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';
|
package/src/utils/output.ts
CHANGED
|
@@ -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);
|
package/src/utils/progress.ts
CHANGED
|
@@ -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
|
*/
|