@fazetitans/fscopy 1.1.3 → 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.
@@ -0,0 +1,31 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { TransformFunction } from '../types.js';
4
+
5
+ export async function loadTransformFunction(transformPath: string): Promise<TransformFunction> {
6
+ const absolutePath = path.resolve(transformPath);
7
+
8
+ if (!fs.existsSync(absolutePath)) {
9
+ throw new Error(`Transform file not found: ${absolutePath}`);
10
+ }
11
+
12
+ try {
13
+ const module = await import(absolutePath);
14
+
15
+ // Look for 'transform' export (default or named)
16
+ const transformFn = module.default?.transform ?? module.transform ?? module.default;
17
+
18
+ if (typeof transformFn !== 'function') {
19
+ throw new TypeError(
20
+ `Transform file must export a 'transform' function. Got: ${typeof transformFn}`
21
+ );
22
+ }
23
+
24
+ return transformFn as TransformFunction;
25
+ } catch (error) {
26
+ if ((error as Error).message.includes('Transform file')) {
27
+ throw error;
28
+ }
29
+ throw new Error(`Failed to load transform file: ${(error as Error).message}`);
30
+ }
31
+ }
package/src/types.ts CHANGED
@@ -34,6 +34,16 @@ export interface Config {
34
34
  rateLimit: number;
35
35
  skipOversized: boolean;
36
36
  json: boolean;
37
+ transformSamples: number;
38
+ detectConflicts: boolean;
39
+ maxDepth: number;
40
+ verifyIntegrity: boolean;
41
+ }
42
+
43
+ export interface ConflictInfo {
44
+ collection: string;
45
+ docId: string;
46
+ reason: string;
37
47
  }
38
48
 
39
49
  export interface Stats {
@@ -41,6 +51,8 @@ export interface Stats {
41
51
  documentsTransferred: number;
42
52
  documentsDeleted: number;
43
53
  errors: number;
54
+ conflicts: number;
55
+ integrityErrors: number;
44
56
  }
45
57
 
46
58
  export interface TransferState {
@@ -78,6 +90,7 @@ export interface CliArgs {
78
90
  destProject?: string;
79
91
  yes: boolean;
80
92
  log?: string;
93
+ maxLogSize?: string;
81
94
  retries: number;
82
95
  quiet: boolean;
83
96
  where?: string[];
@@ -98,4 +111,9 @@ export interface CliArgs {
98
111
  rateLimit?: number;
99
112
  skipOversized?: boolean;
100
113
  json?: boolean;
114
+ transformSamples?: number;
115
+ detectConflicts?: boolean;
116
+ maxDepth?: number;
117
+ verifyIntegrity?: boolean;
118
+ validateOnly?: boolean;
101
119
  }
@@ -3,10 +3,10 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
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 };
6
+ // Check for explicit credentials file (non-empty string)
7
+ const envCredPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
8
+ if (envCredPath && envCredPath.length > 0) {
9
+ return { exists: fs.existsSync(envCredPath), path: envCredPath };
10
10
  }
11
11
 
12
12
  // Check for Application Default Credentials
@@ -20,6 +20,11 @@ export function checkCredentialsExist(): { exists: boolean; path: string } {
20
20
  }
21
21
 
22
22
  export function ensureCredentials(): void {
23
+ // Skip credentials check in test environment
24
+ if (process.env.FSCOPY_SKIP_CREDENTIALS_CHECK === '1') {
25
+ return;
26
+ }
27
+
23
28
  const { exists, path: credPath } = checkCredentialsExist();
24
29
 
25
30
  if (!exists) {
@@ -18,10 +18,7 @@ export const FIRESTORE_MAX_DOC_SIZE = 1024 * 1024;
18
18
  * - GeoPoints: 16 bytes
19
19
  * - References: document path length + 1
20
20
  */
21
- export function estimateDocumentSize(
22
- data: Record<string, unknown>,
23
- docPath?: string
24
- ): number {
21
+ export function estimateDocumentSize(data: Record<string, unknown>, docPath?: string): number {
25
22
  let size = 0;
26
23
 
27
24
  // Document name (path) contributes to size
@@ -34,85 +31,59 @@ export function estimateDocumentSize(
34
31
  return size;
35
32
  }
36
33
 
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
- }
34
+ function isFirestoreTimestamp(value: object): boolean {
35
+ return '_seconds' in value && '_nanoseconds' in value;
36
+ }
49
37
 
50
- if (typeof value === 'string') {
51
- // UTF-8 encoded length
52
- return Buffer.byteLength(value, 'utf8') + 1;
53
- }
38
+ function isGeoPoint(value: object): boolean {
39
+ return '_latitude' in value && '_longitude' in value;
40
+ }
54
41
 
55
- if (value instanceof Date) {
56
- return 8;
57
- }
42
+ function isDocumentReference(value: object): boolean {
43
+ return '_path' in value && typeof (value as { _path: unknown })._path === 'object';
44
+ }
58
45
 
59
- // Firestore Timestamp
60
- if (
61
- value &&
62
- typeof value === 'object' &&
63
- '_seconds' in value &&
64
- '_nanoseconds' in value
65
- ) {
66
- return 8;
46
+ function getDocRefSize(value: object): number {
47
+ const pathObj = (value as { _path: { segments?: string[] } })._path;
48
+ if (pathObj.segments) {
49
+ return pathObj.segments.join('/').length + 1;
67
50
  }
51
+ return 16; // Approximate
52
+ }
68
53
 
69
- // GeoPoint
70
- if (
71
- value &&
72
- typeof value === 'object' &&
73
- '_latitude' in value &&
74
- '_longitude' in value
75
- ) {
76
- return 16;
54
+ function estimateArraySize(arr: unknown[]): number {
55
+ let size = 0;
56
+ for (const item of arr) {
57
+ size += estimateValueSize(item);
77
58
  }
59
+ return size;
60
+ }
78
61
 
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
62
+ function estimateObjectSize(obj: object): number {
63
+ let size = 0;
64
+ for (const [key, val] of Object.entries(obj)) {
65
+ size += key.length + 1; // Key size
66
+ size += estimateValueSize(val); // Value size
91
67
  }
68
+ return size;
69
+ }
92
70
 
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
- }
71
+ function estimateValueSize(value: unknown): number {
72
+ if (value === null || value === undefined) return 1;
73
+ if (typeof value === 'boolean') return 1;
74
+ if (typeof value === 'number') return 8;
75
+ if (typeof value === 'string') return Buffer.byteLength(value, 'utf8') + 1;
76
+ if (value instanceof Date) return 8;
101
77
 
102
- // Map/Object
103
78
  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;
79
+ if (isFirestoreTimestamp(value)) return 8;
80
+ if (isGeoPoint(value)) return 16;
81
+ if (isDocumentReference(value)) return getDocRefSize(value);
82
+ if (Array.isArray(value)) return estimateArraySize(value);
83
+ return estimateObjectSize(value);
112
84
  }
113
85
 
114
- // Unknown type - estimate as small value
115
- return 8;
86
+ return 8; // Unknown type
116
87
  }
117
88
 
118
89
  /**
@@ -19,7 +19,7 @@ const errorMap: Record<string, FirebaseErrorInfo> = {
19
19
  message: 'Permission denied',
20
20
  suggestion: 'Ensure you have Firestore read/write access on this project',
21
21
  },
22
- 'PERMISSION_DENIED': {
22
+ PERMISSION_DENIED: {
23
23
  message: 'Permission denied',
24
24
  suggestion: 'Ensure you have Firestore read/write access on this project',
25
25
  },
@@ -1,5 +1,6 @@
1
1
  export { withRetry, type RetryOptions } from './retry.js';
2
- export { Logger } from './logger.js';
2
+ export { Output, type OutputOptions } from './output.js';
3
+ export { ProgressBarWrapper, type ProgressBarOptions } from './progress.js';
3
4
  export { checkCredentialsExist, ensureCredentials } from './credentials.js';
4
5
  export { matchesExcludePattern } from './patterns.js';
5
6
  export { formatFirebaseError, logFirebaseError, type FirebaseErrorInfo } from './errors.js';
@@ -0,0 +1,122 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * Compute a SHA-256 hash of document data.
5
+ * The data is serialized to a deterministic JSON string before hashing.
6
+ */
7
+ export function hashDocumentData(data: Record<string, unknown>): string {
8
+ const serialized = serializeForHash(data);
9
+ return createHash('sha256').update(serialized).digest('hex');
10
+ }
11
+
12
+ /**
13
+ * Serialize document data to a deterministic JSON string.
14
+ * Keys are sorted alphabetically at each level for consistency.
15
+ */
16
+ function serializeForHash(value: unknown): string {
17
+ if (value === null || value === undefined) {
18
+ return 'null';
19
+ }
20
+
21
+ if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
22
+ return JSON.stringify(value);
23
+ }
24
+
25
+ // Handle Date objects
26
+ if (value instanceof Date) {
27
+ return JSON.stringify(value.toISOString());
28
+ }
29
+
30
+ // Handle Firestore Timestamp
31
+ if (isFirestoreTimestamp(value)) {
32
+ return JSON.stringify({ _seconds: value.seconds, _nanoseconds: value.nanoseconds });
33
+ }
34
+
35
+ // Handle Firestore GeoPoint
36
+ if (isFirestoreGeoPoint(value)) {
37
+ return JSON.stringify({ _latitude: value.latitude, _longitude: value.longitude });
38
+ }
39
+
40
+ // Handle Firestore DocumentReference
41
+ if (isFirestoreDocumentReference(value)) {
42
+ return JSON.stringify({ _path: value.path });
43
+ }
44
+
45
+ // Handle arrays
46
+ if (Array.isArray(value)) {
47
+ const elements = value.map((item) => serializeForHash(item));
48
+ return `[${elements.join(',')}]`;
49
+ }
50
+
51
+ // Handle objects - sort keys for deterministic output
52
+ if (typeof value === 'object') {
53
+ const obj = value as Record<string, unknown>;
54
+ const sortedKeys = Object.keys(obj).sort((a, b) => a.localeCompare(b));
55
+ const pairs = sortedKeys.map(
56
+ (key) => `${JSON.stringify(key)}:${serializeForHash(obj[key])}`
57
+ );
58
+ return `{${pairs.join(',')}}`;
59
+ }
60
+
61
+ // Fallback for unknown types - should not reach here normally
62
+ return '"[unknown]"';
63
+ }
64
+
65
+ /**
66
+ * Compare two document hashes.
67
+ */
68
+ export function compareHashes(sourceHash: string, destHash: string): boolean {
69
+ return sourceHash === destHash;
70
+ }
71
+
72
+ // Type guards for Firestore types
73
+
74
+ interface FirestoreTimestamp {
75
+ seconds: number;
76
+ nanoseconds: number;
77
+ toDate?: () => Date;
78
+ }
79
+
80
+ interface FirestoreGeoPoint {
81
+ latitude: number;
82
+ longitude: number;
83
+ }
84
+
85
+ interface FirestoreDocumentReference {
86
+ path: string;
87
+ id: string;
88
+ }
89
+
90
+ function isFirestoreTimestamp(value: unknown): value is FirestoreTimestamp {
91
+ return (
92
+ typeof value === 'object' &&
93
+ value !== null &&
94
+ 'seconds' in value &&
95
+ 'nanoseconds' in value &&
96
+ typeof (value as FirestoreTimestamp).seconds === 'number' &&
97
+ typeof (value as FirestoreTimestamp).nanoseconds === 'number'
98
+ );
99
+ }
100
+
101
+ function isFirestoreGeoPoint(value: unknown): value is FirestoreGeoPoint {
102
+ return (
103
+ typeof value === 'object' &&
104
+ value !== null &&
105
+ 'latitude' in value &&
106
+ 'longitude' in value &&
107
+ typeof (value as FirestoreGeoPoint).latitude === 'number' &&
108
+ typeof (value as FirestoreGeoPoint).longitude === 'number' &&
109
+ !('seconds' in value)
110
+ );
111
+ }
112
+
113
+ function isFirestoreDocumentReference(value: unknown): value is FirestoreDocumentReference {
114
+ return (
115
+ typeof value === 'object' &&
116
+ value !== null &&
117
+ 'path' in value &&
118
+ 'id' in value &&
119
+ typeof (value as FirestoreDocumentReference).path === 'string' &&
120
+ typeof (value as FirestoreDocumentReference).id === 'string'
121
+ );
122
+ }
@@ -1,13 +1,28 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import type { Stats, LogEntry } from '../types.js';
3
4
 
5
+ export interface LoggerOptions {
6
+ logPath?: string;
7
+ maxSize?: number; // Max size in bytes (0 = unlimited)
8
+ maxFiles?: number; // Max number of rotated files to keep
9
+ }
10
+
4
11
  export class Logger {
5
12
  private readonly logPath: string | undefined;
6
13
  private readonly entries: LogEntry[] = [];
7
14
  private readonly startTime: Date;
15
+ private readonly maxSize: number;
16
+ private readonly maxFiles: number;
8
17
 
9
- constructor(logPath?: string) {
10
- this.logPath = logPath;
18
+ constructor(optionsOrPath?: LoggerOptions | string) {
19
+ // Backward compatibility: accept string as logPath
20
+ const options: LoggerOptions =
21
+ typeof optionsOrPath === 'string' ? { logPath: optionsOrPath } : (optionsOrPath ?? {});
22
+
23
+ this.logPath = options.logPath;
24
+ this.maxSize = options.maxSize ?? 0;
25
+ this.maxFiles = options.maxFiles ?? 5;
11
26
  this.startTime = new Date();
12
27
  }
13
28
 
@@ -43,18 +58,59 @@ export class Logger {
43
58
 
44
59
  init(): void {
45
60
  if (this.logPath) {
61
+ this.rotateIfNeeded();
46
62
  const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
47
63
  fs.writeFileSync(this.logPath, header);
48
64
  }
49
65
  }
50
66
 
67
+ /**
68
+ * Rotate log file if it exceeds maxSize.
69
+ * Creates numbered backups: log.1, log.2, etc.
70
+ */
71
+ private rotateIfNeeded(): void {
72
+ if (!this.logPath || this.maxSize <= 0) return;
73
+ if (!fs.existsSync(this.logPath)) return;
74
+
75
+ const stats = fs.statSync(this.logPath);
76
+ if (stats.size < this.maxSize) return;
77
+
78
+ // Rotate existing backups
79
+ const dir = path.dirname(this.logPath);
80
+ const ext = path.extname(this.logPath);
81
+ const base = path.basename(this.logPath, ext);
82
+
83
+ // Delete oldest if at max
84
+ const oldestPath = path.join(dir, `${base}.${this.maxFiles}${ext}`);
85
+ if (fs.existsSync(oldestPath)) {
86
+ fs.unlinkSync(oldestPath);
87
+ }
88
+
89
+ // Shift existing backups: .4 -> .5, .3 -> .4, etc.
90
+ for (let i = this.maxFiles - 1; i >= 1; i--) {
91
+ const from = path.join(dir, `${base}.${i}${ext}`);
92
+ const to = path.join(dir, `${base}.${i + 1}${ext}`);
93
+ if (fs.existsSync(from)) {
94
+ fs.renameSync(from, to);
95
+ }
96
+ }
97
+
98
+ // Rename current to .1
99
+ const backupPath = path.join(dir, `${base}.1${ext}`);
100
+ fs.renameSync(this.logPath, backupPath);
101
+ }
102
+
51
103
  summary(stats: Stats, duration: string): void {
52
104
  if (this.logPath) {
53
105
  let summary = `\n# Summary\n# Collections: ${stats.collectionsProcessed}\n`;
54
106
  if (stats.documentsDeleted > 0) {
55
107
  summary += `# Deleted: ${stats.documentsDeleted}\n`;
56
108
  }
57
- summary += `# Transferred: ${stats.documentsTransferred}\n# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
109
+ summary += `# Transferred: ${stats.documentsTransferred}\n`;
110
+ if (stats.conflicts > 0) {
111
+ summary += `# Conflicts: ${stats.conflicts}\n`;
112
+ }
113
+ summary += `# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
58
114
  fs.appendFileSync(this.logPath, summary);
59
115
  }
60
116
  }