@fazetitans/fscopy 1.1.0 → 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.
@@ -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
+ }