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