@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/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ // =============================================================================
2
+ // Shared Types
3
+ // =============================================================================
4
+
5
+ export interface WhereFilter {
6
+ field: string;
7
+ operator: FirebaseFirestore.WhereFilterOp;
8
+ value: string | number | boolean;
9
+ }
10
+
11
+ export interface Config {
12
+ collections: string[];
13
+ includeSubcollections: boolean;
14
+ dryRun: boolean;
15
+ batchSize: number;
16
+ limit: number;
17
+ sourceProject: string | null;
18
+ destProject: string | null;
19
+ retries: number;
20
+ where: WhereFilter[];
21
+ exclude: string[];
22
+ merge: boolean;
23
+ parallel: number;
24
+ clear: boolean;
25
+ deleteMissing: boolean;
26
+ transform: string | null;
27
+ renameCollection: Record<string, string>;
28
+ idPrefix: string | null;
29
+ idSuffix: string | null;
30
+ webhook: string | null;
31
+ resume: boolean;
32
+ stateFile: string;
33
+ verify: boolean;
34
+ rateLimit: number;
35
+ skipOversized: boolean;
36
+ json: boolean;
37
+ }
38
+
39
+ export interface Stats {
40
+ collectionsProcessed: number;
41
+ documentsTransferred: number;
42
+ documentsDeleted: number;
43
+ errors: number;
44
+ }
45
+
46
+ export interface TransferState {
47
+ version: number;
48
+ sourceProject: string;
49
+ destProject: string;
50
+ collections: string[];
51
+ startedAt: string;
52
+ updatedAt: string;
53
+ completedDocs: Record<string, string[]>; // collectionPath -> array of doc IDs
54
+ stats: Stats;
55
+ }
56
+
57
+ export interface LogEntry {
58
+ timestamp: string;
59
+ level: string;
60
+ message: string;
61
+ [key: string]: unknown;
62
+ }
63
+
64
+ export type TransformFunction = (
65
+ doc: Record<string, unknown>,
66
+ meta: { id: string; path: string }
67
+ ) => Record<string, unknown> | null;
68
+
69
+ export interface CliArgs {
70
+ init?: string;
71
+ config?: string;
72
+ collections?: string[];
73
+ includeSubcollections?: boolean;
74
+ dryRun?: boolean;
75
+ batchSize?: number;
76
+ limit?: number;
77
+ sourceProject?: string;
78
+ destProject?: string;
79
+ yes: boolean;
80
+ log?: string;
81
+ retries: number;
82
+ quiet: boolean;
83
+ where?: string[];
84
+ exclude?: string[];
85
+ merge?: boolean;
86
+ parallel?: number;
87
+ clear?: boolean;
88
+ deleteMissing?: boolean;
89
+ interactive?: boolean;
90
+ transform?: string;
91
+ renameCollection?: string[];
92
+ idPrefix?: string;
93
+ idSuffix?: string;
94
+ webhook?: string;
95
+ resume?: boolean;
96
+ stateFile?: string;
97
+ verify?: boolean;
98
+ rateLimit?: number;
99
+ skipOversized?: boolean;
100
+ json?: boolean;
101
+ }
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ export function checkCredentialsExist(): { exists: boolean; path: string } {
6
+ // Check for explicit credentials file
7
+ if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
8
+ const credPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
9
+ return { exists: fs.existsSync(credPath), path: credPath };
10
+ }
11
+
12
+ // Check for Application Default Credentials
13
+ const adcPath = path.join(
14
+ os.homedir(),
15
+ '.config',
16
+ 'gcloud',
17
+ 'application_default_credentials.json'
18
+ );
19
+ return { exists: fs.existsSync(adcPath), path: adcPath };
20
+ }
21
+
22
+ export function ensureCredentials(): void {
23
+ const { exists, path: credPath } = checkCredentialsExist();
24
+
25
+ if (!exists) {
26
+ console.error('\n❌ Google Cloud credentials not found.');
27
+ console.error(` Expected at: ${credPath}\n`);
28
+ console.error(' Run this command to authenticate:');
29
+ console.error(' gcloud auth application-default login\n');
30
+ process.exit(1);
31
+ }
32
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Firestore maximum document size in bytes (1 MiB)
3
+ */
4
+ export const FIRESTORE_MAX_DOC_SIZE = 1024 * 1024;
5
+
6
+ /**
7
+ * Estimate the size of a Firestore document in bytes.
8
+ * This is an approximation - actual Firestore storage may differ slightly.
9
+ *
10
+ * Rules:
11
+ * - Strings: UTF-8 encoded length + 1
12
+ * - Numbers: 8 bytes
13
+ * - Booleans: 1 byte
14
+ * - Null: 1 byte
15
+ * - Timestamps: 8 bytes
16
+ * - Arrays: sum of element sizes
17
+ * - Maps: sum of key + value sizes
18
+ * - GeoPoints: 16 bytes
19
+ * - References: document path length + 1
20
+ */
21
+ export function estimateDocumentSize(
22
+ data: Record<string, unknown>,
23
+ docPath?: string
24
+ ): number {
25
+ let size = 0;
26
+
27
+ // Document name (path) contributes to size
28
+ if (docPath) {
29
+ size += docPath.length + 1;
30
+ }
31
+
32
+ size += estimateValueSize(data);
33
+
34
+ return size;
35
+ }
36
+
37
+ function estimateValueSize(value: unknown): number {
38
+ if (value === null || value === undefined) {
39
+ return 1;
40
+ }
41
+
42
+ if (typeof value === 'boolean') {
43
+ return 1;
44
+ }
45
+
46
+ if (typeof value === 'number') {
47
+ return 8;
48
+ }
49
+
50
+ if (typeof value === 'string') {
51
+ // UTF-8 encoded length
52
+ return Buffer.byteLength(value, 'utf8') + 1;
53
+ }
54
+
55
+ if (value instanceof Date) {
56
+ return 8;
57
+ }
58
+
59
+ // Firestore Timestamp
60
+ if (
61
+ value &&
62
+ typeof value === 'object' &&
63
+ '_seconds' in value &&
64
+ '_nanoseconds' in value
65
+ ) {
66
+ return 8;
67
+ }
68
+
69
+ // GeoPoint
70
+ if (
71
+ value &&
72
+ typeof value === 'object' &&
73
+ '_latitude' in value &&
74
+ '_longitude' in value
75
+ ) {
76
+ return 16;
77
+ }
78
+
79
+ // DocumentReference
80
+ if (
81
+ value &&
82
+ typeof value === 'object' &&
83
+ '_path' in value &&
84
+ typeof (value as { _path: unknown })._path === 'object'
85
+ ) {
86
+ const pathObj = (value as { _path: { segments?: string[] } })._path;
87
+ if (pathObj.segments) {
88
+ return pathObj.segments.join('/').length + 1;
89
+ }
90
+ return 16; // Approximate
91
+ }
92
+
93
+ // Array
94
+ if (Array.isArray(value)) {
95
+ let size = 0;
96
+ for (const item of value) {
97
+ size += estimateValueSize(item);
98
+ }
99
+ return size;
100
+ }
101
+
102
+ // Map/Object
103
+ if (typeof value === 'object') {
104
+ let size = 0;
105
+ for (const [key, val] of Object.entries(value)) {
106
+ // Key size (field name)
107
+ size += key.length + 1;
108
+ // Value size
109
+ size += estimateValueSize(val);
110
+ }
111
+ return size;
112
+ }
113
+
114
+ // Unknown type - estimate as small value
115
+ return 8;
116
+ }
117
+
118
+ /**
119
+ * Format byte size to human-readable string
120
+ */
121
+ export function formatBytes(bytes: number): string {
122
+ if (bytes < 1024) {
123
+ return `${bytes} B`;
124
+ }
125
+ if (bytes < 1024 * 1024) {
126
+ return `${(bytes / 1024).toFixed(1)} KB`;
127
+ }
128
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
129
+ }
@@ -0,0 +1,157 @@
1
+ export interface FirebaseErrorInfo {
2
+ message: string;
3
+ suggestion?: string;
4
+ }
5
+
6
+ const errorMap: Record<string, FirebaseErrorInfo> = {
7
+ // Authentication errors
8
+ 'auth/invalid-credential': {
9
+ message: 'Invalid credentials',
10
+ suggestion: 'Run "gcloud auth application-default login" to authenticate',
11
+ },
12
+ 'app/invalid-credential': {
13
+ message: 'Invalid application credentials',
14
+ suggestion: 'Run "gcloud auth application-default login" to authenticate',
15
+ },
16
+
17
+ // Permission errors
18
+ 'permission-denied': {
19
+ message: 'Permission denied',
20
+ suggestion: 'Ensure you have Firestore read/write access on this project',
21
+ },
22
+ 'PERMISSION_DENIED': {
23
+ message: 'Permission denied',
24
+ suggestion: 'Ensure you have Firestore read/write access on this project',
25
+ },
26
+
27
+ // Network errors
28
+ unavailable: {
29
+ message: 'Service unavailable',
30
+ suggestion: 'Check your internet connection and try again',
31
+ },
32
+ UNAVAILABLE: {
33
+ message: 'Service unavailable',
34
+ suggestion: 'Check your internet connection and try again',
35
+ },
36
+
37
+ // Not found errors
38
+ 'not-found': {
39
+ message: 'Resource not found',
40
+ suggestion: 'Verify the project ID and collection path are correct',
41
+ },
42
+ NOT_FOUND: {
43
+ message: 'Resource not found',
44
+ suggestion: 'Verify the project ID and collection path are correct',
45
+ },
46
+
47
+ // Quota errors
48
+ 'resource-exhausted': {
49
+ message: 'Quota exceeded',
50
+ suggestion: 'Try reducing --batch-size or --parallel, or wait and retry later',
51
+ },
52
+ RESOURCE_EXHAUSTED: {
53
+ message: 'Quota exceeded',
54
+ suggestion: 'Try reducing --batch-size or --parallel, or wait and retry later',
55
+ },
56
+
57
+ // Invalid argument errors
58
+ 'invalid-argument': {
59
+ message: 'Invalid argument',
60
+ suggestion: 'Check your query filters and document data',
61
+ },
62
+ INVALID_ARGUMENT: {
63
+ message: 'Invalid argument',
64
+ suggestion: 'Check your query filters and document data',
65
+ },
66
+
67
+ // Deadline exceeded
68
+ 'deadline-exceeded': {
69
+ message: 'Request timeout',
70
+ suggestion: 'Try reducing --batch-size or check your network connection',
71
+ },
72
+ DEADLINE_EXCEEDED: {
73
+ message: 'Request timeout',
74
+ suggestion: 'Try reducing --batch-size or check your network connection',
75
+ },
76
+
77
+ // Already exists
78
+ 'already-exists': {
79
+ message: 'Document already exists',
80
+ suggestion: 'Use --merge option to update existing documents',
81
+ },
82
+ ALREADY_EXISTS: {
83
+ message: 'Document already exists',
84
+ suggestion: 'Use --merge option to update existing documents',
85
+ },
86
+
87
+ // Aborted
88
+ aborted: {
89
+ message: 'Operation aborted',
90
+ suggestion: 'A concurrent operation conflicted. Retry the transfer',
91
+ },
92
+ ABORTED: {
93
+ message: 'Operation aborted',
94
+ suggestion: 'A concurrent operation conflicted. Retry the transfer',
95
+ },
96
+ };
97
+
98
+ export function formatFirebaseError(error: Error & { code?: string }): FirebaseErrorInfo {
99
+ // Check by error code first
100
+ if (error.code) {
101
+ const mapped = errorMap[error.code];
102
+ if (mapped) {
103
+ return mapped;
104
+ }
105
+ }
106
+
107
+ // Check by error message keywords
108
+ const message = error.message.toLowerCase();
109
+
110
+ if (message.includes('credential') || message.includes('authentication')) {
111
+ return errorMap['app/invalid-credential'];
112
+ }
113
+ if (message.includes('permission') || message.includes('denied')) {
114
+ return errorMap['permission-denied'];
115
+ }
116
+ if (message.includes('unavailable') || message.includes('network')) {
117
+ return errorMap['unavailable'];
118
+ }
119
+ if (message.includes('not found') || message.includes('not_found')) {
120
+ return errorMap['not-found'];
121
+ }
122
+ if (message.includes('quota') || message.includes('exhausted') || message.includes('rate')) {
123
+ return errorMap['resource-exhausted'];
124
+ }
125
+ if (message.includes('timeout') || message.includes('deadline')) {
126
+ return errorMap['deadline-exceeded'];
127
+ }
128
+
129
+ // Default: return original message
130
+ return {
131
+ message: error.message,
132
+ };
133
+ }
134
+
135
+ export function logFirebaseError(
136
+ error: Error & { code?: string },
137
+ context: string,
138
+ logger?: { error: (msg: string, data?: Record<string, unknown>) => void }
139
+ ): void {
140
+ const info = formatFirebaseError(error);
141
+
142
+ console.error(`\n❌ ${context}: ${info.message}`);
143
+ if (info.suggestion) {
144
+ console.error(` Hint: ${info.suggestion}`);
145
+ }
146
+ if (error.code) {
147
+ console.error(` Code: ${error.code}`);
148
+ }
149
+
150
+ if (logger) {
151
+ logger.error(`${context}: ${info.message}`, {
152
+ code: error.code,
153
+ originalMessage: error.message,
154
+ suggestion: info.suggestion,
155
+ });
156
+ }
157
+ }
@@ -0,0 +1,7 @@
1
+ export { withRetry, type RetryOptions } from './retry.js';
2
+ export { Logger } from './logger.js';
3
+ export { checkCredentialsExist, ensureCredentials } from './credentials.js';
4
+ export { matchesExcludePattern } from './patterns.js';
5
+ export { formatFirebaseError, logFirebaseError, type FirebaseErrorInfo } from './errors.js';
6
+ export { RateLimiter } from './rate-limiter.js';
7
+ export { estimateDocumentSize, formatBytes, FIRESTORE_MAX_DOC_SIZE } from './doc-size.js';
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import type { Stats, LogEntry } from '../types.js';
3
+
4
+ export class Logger {
5
+ private readonly logPath: string | undefined;
6
+ private readonly entries: LogEntry[] = [];
7
+ private readonly startTime: Date;
8
+
9
+ constructor(logPath?: string) {
10
+ this.logPath = logPath;
11
+ this.startTime = new Date();
12
+ }
13
+
14
+ log(level: string, message: string, data: Record<string, unknown> = {}): void {
15
+ const entry: LogEntry = {
16
+ timestamp: new Date().toISOString(),
17
+ level,
18
+ message,
19
+ ...data,
20
+ };
21
+ this.entries.push(entry);
22
+
23
+ if (this.logPath) {
24
+ const line =
25
+ `[${entry.timestamp}] [${level}] ${message}` +
26
+ (Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '') +
27
+ '\n';
28
+ fs.appendFileSync(this.logPath, line);
29
+ }
30
+ }
31
+
32
+ info(message: string, data?: Record<string, unknown>): void {
33
+ this.log('INFO', message, data);
34
+ }
35
+
36
+ error(message: string, data?: Record<string, unknown>): void {
37
+ this.log('ERROR', message, data);
38
+ }
39
+
40
+ success(message: string, data?: Record<string, unknown>): void {
41
+ this.log('SUCCESS', message, data);
42
+ }
43
+
44
+ init(): void {
45
+ if (this.logPath) {
46
+ const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
47
+ fs.writeFileSync(this.logPath, header);
48
+ }
49
+ }
50
+
51
+ summary(stats: Stats, duration: string): void {
52
+ if (this.logPath) {
53
+ let summary = `\n# Summary\n# Collections: ${stats.collectionsProcessed}\n`;
54
+ if (stats.documentsDeleted > 0) {
55
+ summary += `# Deleted: ${stats.documentsDeleted}\n`;
56
+ }
57
+ summary += `# Transferred: ${stats.documentsTransferred}\n# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
58
+ fs.appendFileSync(this.logPath, summary);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,13 @@
1
+ export function matchesExcludePattern(path: string, patterns: string[]): boolean {
2
+ for (const pattern of patterns) {
3
+ if (pattern.includes('*')) {
4
+ // Convert glob pattern to regex
5
+ const regex = new RegExp('^' + pattern.replaceAll('*', '.*') + '$');
6
+ if (regex.test(path)) return true;
7
+ } else if (path === pattern || path.endsWith('/' + pattern)) {
8
+ // Exact match or ends with pattern
9
+ return true;
10
+ }
11
+ }
12
+ return false;
13
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Simple rate limiter using token bucket algorithm
3
+ * Limits the rate of operations per second
4
+ */
5
+ export class RateLimiter {
6
+ private tokens: number;
7
+ private lastRefill: number;
8
+ private readonly maxTokens: number;
9
+ private readonly refillRate: number; // tokens per ms
10
+
11
+ /**
12
+ * Create a rate limiter
13
+ * @param docsPerSecond Maximum documents per second (0 = unlimited)
14
+ */
15
+ constructor(docsPerSecond: number) {
16
+ this.maxTokens = docsPerSecond;
17
+ this.tokens = docsPerSecond;
18
+ this.lastRefill = Date.now();
19
+ this.refillRate = docsPerSecond / 1000; // tokens per ms
20
+ }
21
+
22
+ /**
23
+ * Check if rate limiting is enabled
24
+ */
25
+ isEnabled(): boolean {
26
+ return this.maxTokens > 0;
27
+ }
28
+
29
+ /**
30
+ * Wait until we can proceed with the given number of operations
31
+ * @param count Number of operations to perform
32
+ */
33
+ async acquire(count: number = 1): Promise<void> {
34
+ if (!this.isEnabled()) return;
35
+
36
+ // Refill tokens based on time elapsed
37
+ const now = Date.now();
38
+ const elapsed = now - this.lastRefill;
39
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
40
+ this.lastRefill = now;
41
+
42
+ // If we have enough tokens, consume and proceed
43
+ if (this.tokens >= count) {
44
+ this.tokens -= count;
45
+ return;
46
+ }
47
+
48
+ // Wait for tokens to be available
49
+ const tokensNeeded = count - this.tokens;
50
+ const waitTime = Math.ceil(tokensNeeded / this.refillRate);
51
+
52
+ await this.sleep(waitTime);
53
+
54
+ // After waiting, we should have enough tokens
55
+ this.tokens = 0; // We consumed them all
56
+ this.lastRefill = Date.now();
57
+ }
58
+
59
+ private sleep(ms: number): Promise<void> {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+ }
@@ -0,0 +1,29 @@
1
+ export interface RetryOptions {
2
+ retries?: number;
3
+ baseDelay?: number;
4
+ maxDelay?: number;
5
+ onRetry?: (attempt: number, max: number, error: Error, delay: number) => void;
6
+ }
7
+
8
+ export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
9
+ const { retries = 3, baseDelay = 1000, maxDelay = 30000, onRetry } = options;
10
+
11
+ let lastError: Error | undefined;
12
+ for (let attempt = 0; attempt <= retries; attempt++) {
13
+ try {
14
+ return await fn();
15
+ } catch (error) {
16
+ lastError = error as Error;
17
+
18
+ if (attempt < retries) {
19
+ const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
20
+ if (onRetry) {
21
+ onRetry(attempt + 1, retries, lastError, delay);
22
+ }
23
+ await new Promise((resolve) => setTimeout(resolve, delay));
24
+ }
25
+ }
26
+ }
27
+
28
+ throw lastError;
29
+ }