@fazetitans/fscopy 1.1.1 → 1.1.2
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 +3 -2
- package/src/config/defaults.ts +88 -0
- package/src/config/generator.ts +27 -0
- package/src/config/index.ts +15 -0
- package/src/config/parser.ts +261 -0
- package/src/config/validator.ts +29 -0
- package/src/interactive.ts +172 -0
- package/src/state/index.ts +123 -0
- package/src/transfer/clear.ts +157 -0
- package/src/transfer/count.ts +71 -0
- package/src/transfer/helpers.ts +37 -0
- package/src/transfer/index.ts +5 -0
- package/src/transfer/parallel.ts +51 -0
- package/src/transfer/transfer.ts +214 -0
- package/src/types.ts +101 -0
- package/src/utils/credentials.ts +32 -0
- package/src/utils/doc-size.ts +129 -0
- package/src/utils/errors.ts +157 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/logger.ts +61 -0
- package/src/utils/patterns.ts +13 -0
- package/src/utils/rate-limiter.ts +62 -0
- package/src/utils/retry.ts +29 -0
- package/src/webhook/index.ts +128 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import type { Config, TransferState } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export const STATE_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export function loadTransferState(stateFile: string): TransferState | null {
|
|
7
|
+
try {
|
|
8
|
+
if (!fs.existsSync(stateFile)) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const content = fs.readFileSync(stateFile, 'utf-8');
|
|
12
|
+
const state = JSON.parse(content) as TransferState;
|
|
13
|
+
|
|
14
|
+
if (state.version !== STATE_VERSION) {
|
|
15
|
+
console.warn(
|
|
16
|
+
`⚠️ State file version mismatch (expected ${STATE_VERSION}, got ${state.version})`
|
|
17
|
+
);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return state;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`⚠️ Failed to load state file: ${(error as Error).message}`);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveTransferState(stateFile: string, state: TransferState): void {
|
|
29
|
+
state.updatedAt = new Date().toISOString();
|
|
30
|
+
const content = JSON.stringify(state, null, 2);
|
|
31
|
+
const tempFile = `${stateFile}.tmp`;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// Write to temp file first (atomic write pattern)
|
|
35
|
+
fs.writeFileSync(tempFile, content);
|
|
36
|
+
// Rename is atomic on most filesystems
|
|
37
|
+
fs.renameSync(tempFile, stateFile);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// Clean up temp file if it exists
|
|
40
|
+
try {
|
|
41
|
+
if (fs.existsSync(tempFile)) {
|
|
42
|
+
fs.unlinkSync(tempFile);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore cleanup errors
|
|
46
|
+
}
|
|
47
|
+
// Log but don't throw - state save failure shouldn't stop the transfer
|
|
48
|
+
console.error(`⚠️ Failed to save state file: ${(error as Error).message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deleteTransferState(stateFile: string): void {
|
|
53
|
+
try {
|
|
54
|
+
if (fs.existsSync(stateFile)) {
|
|
55
|
+
fs.unlinkSync(stateFile);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Ignore errors when deleting state file
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createInitialState(config: Config): TransferState {
|
|
63
|
+
return {
|
|
64
|
+
version: STATE_VERSION,
|
|
65
|
+
sourceProject: config.sourceProject!,
|
|
66
|
+
destProject: config.destProject!,
|
|
67
|
+
collections: config.collections,
|
|
68
|
+
startedAt: new Date().toISOString(),
|
|
69
|
+
updatedAt: new Date().toISOString(),
|
|
70
|
+
completedDocs: {},
|
|
71
|
+
stats: {
|
|
72
|
+
collectionsProcessed: 0,
|
|
73
|
+
documentsTransferred: 0,
|
|
74
|
+
documentsDeleted: 0,
|
|
75
|
+
errors: 0,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function validateStateForResume(state: TransferState, config: Config): string[] {
|
|
81
|
+
const errors: string[] = [];
|
|
82
|
+
|
|
83
|
+
if (state.sourceProject !== config.sourceProject) {
|
|
84
|
+
errors.push(
|
|
85
|
+
`Source project mismatch: state has "${state.sourceProject}", config has "${config.sourceProject}"`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (state.destProject !== config.destProject) {
|
|
89
|
+
errors.push(
|
|
90
|
+
`Destination project mismatch: state has "${state.destProject}", config has "${config.destProject}"`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if collections are compatible (state collections should be subset of config)
|
|
95
|
+
const configCollections = new Set(config.collections);
|
|
96
|
+
for (const col of state.collections) {
|
|
97
|
+
if (!configCollections.has(col)) {
|
|
98
|
+
errors.push(`State contains collection "${col}" not in current config`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return errors;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isDocCompleted(
|
|
106
|
+
state: TransferState,
|
|
107
|
+
collectionPath: string,
|
|
108
|
+
docId: string
|
|
109
|
+
): boolean {
|
|
110
|
+
const completedInCollection = state.completedDocs[collectionPath];
|
|
111
|
+
return completedInCollection ? completedInCollection.includes(docId) : false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function markDocCompleted(
|
|
115
|
+
state: TransferState,
|
|
116
|
+
collectionPath: string,
|
|
117
|
+
docId: string
|
|
118
|
+
): void {
|
|
119
|
+
if (!state.completedDocs[collectionPath]) {
|
|
120
|
+
state.completedDocs[collectionPath] = [];
|
|
121
|
+
}
|
|
122
|
+
state.completedDocs[collectionPath].push(docId);
|
|
123
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import type { Config } from '../types.js';
|
|
3
|
+
import type { Logger } from '../utils/logger.js';
|
|
4
|
+
import { withRetry } from '../utils/retry.js';
|
|
5
|
+
import { matchesExcludePattern } from '../utils/patterns.js';
|
|
6
|
+
import { getSubcollections, getDestCollectionPath } from './helpers.js';
|
|
7
|
+
|
|
8
|
+
export async function clearCollection(
|
|
9
|
+
db: Firestore,
|
|
10
|
+
collectionPath: string,
|
|
11
|
+
config: Config,
|
|
12
|
+
logger: Logger,
|
|
13
|
+
includeSubcollections: boolean
|
|
14
|
+
): Promise<number> {
|
|
15
|
+
let deletedCount = 0;
|
|
16
|
+
const collectionRef = db.collection(collectionPath);
|
|
17
|
+
const snapshot = await collectionRef.get();
|
|
18
|
+
|
|
19
|
+
if (snapshot.empty) {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Delete subcollections first if enabled
|
|
24
|
+
if (includeSubcollections) {
|
|
25
|
+
for (const doc of snapshot.docs) {
|
|
26
|
+
const subcollections = await getSubcollections(doc.ref);
|
|
27
|
+
for (const subId of subcollections) {
|
|
28
|
+
// Check exclude patterns
|
|
29
|
+
if (matchesExcludePattern(subId, config.exclude)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const subPath = `${collectionPath}/${doc.id}/${subId}`;
|
|
33
|
+
deletedCount += await clearCollection(db, subPath, config, logger, true);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Delete documents in batches
|
|
39
|
+
const docs = snapshot.docs;
|
|
40
|
+
for (let i = 0; i < docs.length; i += config.batchSize) {
|
|
41
|
+
const batch = docs.slice(i, i + config.batchSize);
|
|
42
|
+
const writeBatch = db.batch();
|
|
43
|
+
|
|
44
|
+
for (const doc of batch) {
|
|
45
|
+
writeBatch.delete(doc.ref);
|
|
46
|
+
deletedCount++;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!config.dryRun) {
|
|
50
|
+
await withRetry(() => writeBatch.commit(), {
|
|
51
|
+
retries: config.retries,
|
|
52
|
+
onRetry: (attempt, max, err, delay) => {
|
|
53
|
+
logger.error(`Retry delete ${attempt}/${max} for ${collectionPath}`, {
|
|
54
|
+
error: err.message,
|
|
55
|
+
delay,
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.info(`Deleted ${batch.length} documents from ${collectionPath}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return deletedCount;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function deleteOrphanDocuments(
|
|
68
|
+
sourceDb: Firestore,
|
|
69
|
+
destDb: Firestore,
|
|
70
|
+
sourceCollectionPath: string,
|
|
71
|
+
config: Config,
|
|
72
|
+
logger: Logger
|
|
73
|
+
): Promise<number> {
|
|
74
|
+
let deletedCount = 0;
|
|
75
|
+
|
|
76
|
+
// Get the destination path (may be renamed)
|
|
77
|
+
const destCollectionPath = getDestCollectionPath(sourceCollectionPath, config.renameCollection);
|
|
78
|
+
|
|
79
|
+
// Get all document IDs from source (use select() to only fetch IDs, not data)
|
|
80
|
+
const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).select().get();
|
|
81
|
+
const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
|
|
82
|
+
|
|
83
|
+
// Get all document IDs from destination (use select() to only fetch IDs, not data)
|
|
84
|
+
const destSnapshot = await destDb.collection(destCollectionPath).select().get();
|
|
85
|
+
|
|
86
|
+
// Find orphan documents (in dest but not in source)
|
|
87
|
+
const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
|
|
88
|
+
|
|
89
|
+
if (orphanDocs.length === 0) {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
logger.info(`Found ${orphanDocs.length} orphan documents in ${destCollectionPath}`);
|
|
94
|
+
|
|
95
|
+
// Delete orphan documents in batches
|
|
96
|
+
for (let i = 0; i < orphanDocs.length; i += config.batchSize) {
|
|
97
|
+
const batch = orphanDocs.slice(i, i + config.batchSize);
|
|
98
|
+
const writeBatch = destDb.batch();
|
|
99
|
+
|
|
100
|
+
for (const doc of batch) {
|
|
101
|
+
// If subcollections are included, recursively delete orphans in subcollections first
|
|
102
|
+
if (config.includeSubcollections) {
|
|
103
|
+
const subcollections = await getSubcollections(doc.ref);
|
|
104
|
+
for (const subId of subcollections) {
|
|
105
|
+
if (matchesExcludePattern(subId, config.exclude)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const subPath = `${destCollectionPath}/${doc.id}/${subId}`;
|
|
109
|
+
// For orphan parent docs, clear all subcollection data
|
|
110
|
+
deletedCount += await clearCollection(destDb, subPath, config, logger, true);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
writeBatch.delete(doc.ref);
|
|
115
|
+
deletedCount++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!config.dryRun) {
|
|
119
|
+
await withRetry(() => writeBatch.commit(), {
|
|
120
|
+
retries: config.retries,
|
|
121
|
+
onRetry: (attempt, max, err, delay) => {
|
|
122
|
+
logger.error(
|
|
123
|
+
`Retry delete orphans ${attempt}/${max} for ${destCollectionPath}`,
|
|
124
|
+
{
|
|
125
|
+
error: err.message,
|
|
126
|
+
delay,
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
logger.info(`Deleted ${batch.length} orphan documents from ${destCollectionPath}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Also check subcollections of existing documents for orphans
|
|
137
|
+
if (config.includeSubcollections) {
|
|
138
|
+
for (const sourceDoc of sourceSnapshot.docs) {
|
|
139
|
+
const sourceSubcollections = await getSubcollections(sourceDoc.ref);
|
|
140
|
+
for (const subId of sourceSubcollections) {
|
|
141
|
+
if (matchesExcludePattern(subId, config.exclude)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
|
|
145
|
+
deletedCount += await deleteOrphanDocuments(
|
|
146
|
+
sourceDb,
|
|
147
|
+
destDb,
|
|
148
|
+
subPath,
|
|
149
|
+
config,
|
|
150
|
+
logger
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return deletedCount;
|
|
157
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import type { Config } from '../types.js';
|
|
3
|
+
import { matchesExcludePattern } from '../utils/patterns.js';
|
|
4
|
+
import { getSubcollections } from './helpers.js';
|
|
5
|
+
|
|
6
|
+
export interface CountProgress {
|
|
7
|
+
onCollection?: (path: string, count: number) => void;
|
|
8
|
+
onSubcollection?: (path: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function countDocuments(
|
|
12
|
+
sourceDb: Firestore,
|
|
13
|
+
collectionPath: string,
|
|
14
|
+
config: Config,
|
|
15
|
+
depth: number = 0,
|
|
16
|
+
progress?: CountProgress
|
|
17
|
+
): Promise<number> {
|
|
18
|
+
let count = 0;
|
|
19
|
+
|
|
20
|
+
// Build query with where filters (only at root level)
|
|
21
|
+
let query: FirebaseFirestore.Query = sourceDb.collection(collectionPath);
|
|
22
|
+
if (depth === 0 && config.where.length > 0) {
|
|
23
|
+
for (const filter of config.where) {
|
|
24
|
+
query = query.where(filter.field, filter.operator, filter.value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Use count() aggregation to avoid downloading all documents (much cheaper)
|
|
29
|
+
// But we need document refs for subcollections, so we'll need a different approach
|
|
30
|
+
if (config.includeSubcollections) {
|
|
31
|
+
// When including subcollections, we need to fetch docs to get their refs
|
|
32
|
+
// Use select() to only fetch document IDs, not the data (reduces bandwidth)
|
|
33
|
+
const snapshot = await query.select().get();
|
|
34
|
+
count += snapshot.size;
|
|
35
|
+
|
|
36
|
+
// Report progress for root collections
|
|
37
|
+
if (depth === 0 && progress?.onCollection) {
|
|
38
|
+
progress.onCollection(collectionPath, snapshot.size);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const doc of snapshot.docs) {
|
|
42
|
+
const subcollections = await getSubcollections(doc.ref);
|
|
43
|
+
for (const subId of subcollections) {
|
|
44
|
+
const subPath = `${collectionPath}/${doc.id}/${subId}`;
|
|
45
|
+
|
|
46
|
+
// Check exclude patterns
|
|
47
|
+
if (matchesExcludePattern(subId, config.exclude)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Report subcollection discovery
|
|
52
|
+
if (progress?.onSubcollection) {
|
|
53
|
+
progress.onSubcollection(subPath);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
count += await countDocuments(sourceDb, subPath, config, depth + 1, progress);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// No subcollections: use count() aggregation (1 read instead of N)
|
|
61
|
+
const countSnapshot = await query.count().get();
|
|
62
|
+
count = countSnapshot.data().count;
|
|
63
|
+
|
|
64
|
+
// Report progress for root collections
|
|
65
|
+
if (depth === 0 && progress?.onCollection) {
|
|
66
|
+
progress.onCollection(collectionPath, count);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return count;
|
|
71
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DocumentReference } from 'firebase-admin/firestore';
|
|
2
|
+
|
|
3
|
+
export async function getSubcollections(docRef: DocumentReference): Promise<string[]> {
|
|
4
|
+
const collections = await docRef.listCollections();
|
|
5
|
+
return collections.map((col) => col.id);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getDestCollectionPath(
|
|
9
|
+
sourcePath: string,
|
|
10
|
+
renameMapping: Record<string, string>
|
|
11
|
+
): string {
|
|
12
|
+
// Get the root collection name from the source path
|
|
13
|
+
const rootCollection = sourcePath.split('/')[0];
|
|
14
|
+
|
|
15
|
+
// Check if this root collection should be renamed
|
|
16
|
+
if (renameMapping[rootCollection]) {
|
|
17
|
+
// Replace the root collection name with the destination name
|
|
18
|
+
return renameMapping[rootCollection] + sourcePath.slice(rootCollection.length);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return sourcePath;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDestDocId(
|
|
25
|
+
sourceId: string,
|
|
26
|
+
prefix: string | null,
|
|
27
|
+
suffix: string | null
|
|
28
|
+
): string {
|
|
29
|
+
let destId = sourceId;
|
|
30
|
+
if (prefix) {
|
|
31
|
+
destId = prefix + destId;
|
|
32
|
+
}
|
|
33
|
+
if (suffix) {
|
|
34
|
+
destId = destId + suffix;
|
|
35
|
+
}
|
|
36
|
+
return destId;
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
|
|
2
|
+
export { processInParallel, type ParallelResult } from './parallel.js';
|
|
3
|
+
export { countDocuments, type CountProgress } from './count.js';
|
|
4
|
+
export { clearCollection, deleteOrphanDocuments } from './clear.js';
|
|
5
|
+
export { transferCollection, type TransferContext } from './transfer.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface ParallelResult<R> {
|
|
2
|
+
results: R[];
|
|
3
|
+
errors: Error[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export async function processInParallel<T, R>(
|
|
7
|
+
items: T[],
|
|
8
|
+
concurrency: number,
|
|
9
|
+
processor: (item: T) => Promise<R>
|
|
10
|
+
): Promise<ParallelResult<R>> {
|
|
11
|
+
const results: R[] = [];
|
|
12
|
+
const errors: Error[] = [];
|
|
13
|
+
const queue = [...items];
|
|
14
|
+
const executing: Set<Promise<void>> = new Set();
|
|
15
|
+
|
|
16
|
+
const processNext = async (): Promise<void> => {
|
|
17
|
+
if (queue.length === 0) return;
|
|
18
|
+
|
|
19
|
+
const item = queue.shift()!;
|
|
20
|
+
try {
|
|
21
|
+
const result = await processor(item);
|
|
22
|
+
results.push(result);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Start initial batch of concurrent tasks
|
|
29
|
+
while (executing.size < concurrency && queue.length > 0) {
|
|
30
|
+
const promise = processNext().then(() => {
|
|
31
|
+
executing.delete(promise);
|
|
32
|
+
});
|
|
33
|
+
executing.add(promise);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Process remaining items as slots become available
|
|
37
|
+
while (queue.length > 0 || executing.size > 0) {
|
|
38
|
+
if (executing.size > 0) {
|
|
39
|
+
await Promise.race(executing);
|
|
40
|
+
}
|
|
41
|
+
// Fill up to concurrency limit
|
|
42
|
+
while (executing.size < concurrency && queue.length > 0) {
|
|
43
|
+
const promise = processNext().then(() => {
|
|
44
|
+
executing.delete(promise);
|
|
45
|
+
});
|
|
46
|
+
executing.add(promise);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { results, errors };
|
|
51
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Firestore, WriteBatch } from 'firebase-admin/firestore';
|
|
2
|
+
import type cliProgress from 'cli-progress';
|
|
3
|
+
import type { Config, Stats, TransferState, TransformFunction } from '../types.js';
|
|
4
|
+
import type { Logger } from '../utils/logger.js';
|
|
5
|
+
import type { RateLimiter } from '../utils/rate-limiter.js';
|
|
6
|
+
import { withRetry } from '../utils/retry.js';
|
|
7
|
+
import { matchesExcludePattern } from '../utils/patterns.js';
|
|
8
|
+
import { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from '../utils/doc-size.js';
|
|
9
|
+
import { isDocCompleted, markDocCompleted, saveTransferState } from '../state/index.js';
|
|
10
|
+
import { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
|
|
11
|
+
|
|
12
|
+
export interface TransferContext {
|
|
13
|
+
sourceDb: Firestore;
|
|
14
|
+
destDb: Firestore;
|
|
15
|
+
config: Config;
|
|
16
|
+
stats: Stats;
|
|
17
|
+
logger: Logger;
|
|
18
|
+
progressBar: cliProgress.SingleBar | null;
|
|
19
|
+
transformFn: TransformFunction | null;
|
|
20
|
+
state: TransferState | null;
|
|
21
|
+
rateLimiter: RateLimiter | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function transferCollection(
|
|
25
|
+
ctx: TransferContext,
|
|
26
|
+
collectionPath: string,
|
|
27
|
+
depth: number = 0
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const { sourceDb, destDb, config, stats, logger, progressBar, transformFn, state, rateLimiter } = ctx;
|
|
30
|
+
|
|
31
|
+
// Get the destination path (may be renamed)
|
|
32
|
+
const destCollectionPath = getDestCollectionPath(collectionPath, config.renameCollection);
|
|
33
|
+
|
|
34
|
+
const sourceCollectionRef = sourceDb.collection(collectionPath);
|
|
35
|
+
let query: FirebaseFirestore.Query = sourceCollectionRef;
|
|
36
|
+
|
|
37
|
+
// Apply where filters (only at root level)
|
|
38
|
+
if (depth === 0 && config.where.length > 0) {
|
|
39
|
+
for (const filter of config.where) {
|
|
40
|
+
query = query.where(filter.field, filter.operator, filter.value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (config.limit > 0 && depth === 0) {
|
|
45
|
+
query = query.limit(config.limit);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const snapshot = await withRetry(() => query.get(), {
|
|
49
|
+
retries: config.retries,
|
|
50
|
+
onRetry: (attempt, max, err, delay) => {
|
|
51
|
+
logger.error(`Retry ${attempt}/${max} for ${collectionPath}`, {
|
|
52
|
+
error: err.message,
|
|
53
|
+
delay,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (snapshot.empty) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
stats.collectionsProcessed++;
|
|
63
|
+
logger.info(`Processing collection: ${collectionPath}`, { documents: snapshot.size });
|
|
64
|
+
|
|
65
|
+
const docs = snapshot.docs;
|
|
66
|
+
const batchDocIds: string[] = []; // Track docs in current batch for state saving
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < docs.length; i += config.batchSize) {
|
|
69
|
+
const batch = docs.slice(i, i + config.batchSize);
|
|
70
|
+
const destBatch: WriteBatch = destDb.batch();
|
|
71
|
+
batchDocIds.length = 0; // Clear for new batch
|
|
72
|
+
|
|
73
|
+
for (const doc of batch) {
|
|
74
|
+
// Skip if already completed (resume mode)
|
|
75
|
+
if (state && isDocCompleted(state, collectionPath, doc.id)) {
|
|
76
|
+
if (progressBar) {
|
|
77
|
+
progressBar.increment();
|
|
78
|
+
}
|
|
79
|
+
stats.documentsTransferred++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get destination document ID (with optional prefix/suffix)
|
|
84
|
+
const destDocId = getDestDocId(doc.id, config.idPrefix, config.idSuffix);
|
|
85
|
+
const destDocRef = destDb.collection(destCollectionPath).doc(destDocId);
|
|
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
|
+
}
|
|
126
|
+
|
|
127
|
+
// Check document size
|
|
128
|
+
const docSize = estimateDocumentSize(docData, `${destCollectionPath}/${destDocId}`);
|
|
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
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!config.dryRun) {
|
|
149
|
+
// Use merge option if enabled
|
|
150
|
+
if (config.merge) {
|
|
151
|
+
destBatch.set(destDocRef, docData, { merge: true });
|
|
152
|
+
} else {
|
|
153
|
+
destBatch.set(destDocRef, docData);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
batchDocIds.push(doc.id);
|
|
158
|
+
stats.documentsTransferred++;
|
|
159
|
+
if (progressBar) {
|
|
160
|
+
progressBar.increment();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
logger.info('Transferred document', {
|
|
164
|
+
source: collectionPath,
|
|
165
|
+
dest: destCollectionPath,
|
|
166
|
+
sourceDocId: doc.id,
|
|
167
|
+
destDocId: destDocId,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (config.includeSubcollections) {
|
|
171
|
+
const subcollections = await getSubcollections(doc.ref);
|
|
172
|
+
|
|
173
|
+
for (const subcollectionId of subcollections) {
|
|
174
|
+
// Check exclude patterns
|
|
175
|
+
if (matchesExcludePattern(subcollectionId, config.exclude)) {
|
|
176
|
+
logger.info(`Skipping excluded subcollection: ${subcollectionId}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const subcollectionPath = `${collectionPath}/${doc.id}/${subcollectionId}`;
|
|
181
|
+
|
|
182
|
+
await transferCollection(
|
|
183
|
+
{ ...ctx, config: { ...config, limit: 0, where: [] } },
|
|
184
|
+
subcollectionPath,
|
|
185
|
+
depth + 1
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!config.dryRun && batch.length > 0) {
|
|
192
|
+
// Apply rate limiting before commit
|
|
193
|
+
if (rateLimiter) {
|
|
194
|
+
await rateLimiter.acquire(batchDocIds.length);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await withRetry(() => destBatch.commit(), {
|
|
198
|
+
retries: config.retries,
|
|
199
|
+
onRetry: (attempt, max, err, delay) => {
|
|
200
|
+
logger.error(`Retry commit ${attempt}/${max}`, { error: err.message, delay });
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Save state after successful batch commit (for resume support)
|
|
205
|
+
if (state && batchDocIds.length > 0) {
|
|
206
|
+
for (const docId of batchDocIds) {
|
|
207
|
+
markDocCompleted(state, collectionPath, docId);
|
|
208
|
+
}
|
|
209
|
+
state.stats = { ...stats };
|
|
210
|
+
saveTransferState(config.stateFile, state);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|