@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.
- package/README.md +76 -33
- package/package.json +3 -3
- package/src/cli.ts +71 -621
- 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/progress.ts +102 -0
- package/src/utils/rate-limiter.ts +4 -2
- package/src/webhook/index.ts +6 -6
|
@@ -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
|
}
|
package/src/utils/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
return { exists: fs.existsSync(
|
|
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) {
|
package/src/utils/doc-size.ts
CHANGED
|
@@ -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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
38
|
+
function isGeoPoint(value: object): boolean {
|
|
39
|
+
return '_latitude' in value && '_longitude' in value;
|
|
40
|
+
}
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
42
|
+
function isDocumentReference(value: object): boolean {
|
|
43
|
+
return '_path' in value && typeof (value as { _path: unknown })._path === 'object';
|
|
44
|
+
}
|
|
58
45
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
115
|
-
return 8;
|
|
86
|
+
return 8; // Unknown type
|
|
116
87
|
}
|
|
117
88
|
|
|
118
89
|
/**
|
package/src/utils/errors.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
+
PERMISSION_DENIED: {
|
|
23
23
|
message: 'Permission denied',
|
|
24
24
|
suggestion: 'Ensure you have Firestore read/write access on this project',
|
|
25
25
|
},
|
package/src/utils/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { withRetry, type RetryOptions } from './retry.js';
|
|
2
|
-
export {
|
|
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
|
+
}
|
package/src/utils/logger.ts
CHANGED
|
@@ -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(
|
|
10
|
-
|
|
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
|
|
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
|
}
|