@fazetitans/fscopy 1.2.1 → 1.3.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/package.json +6 -4
- package/src/cli.ts +14 -7
- package/src/config/index.ts +1 -1
- package/src/config/validator.ts +27 -1
- package/src/constants.ts +30 -0
- package/src/interactive.ts +3 -2
- package/src/orchestrator.ts +71 -16
- package/src/output/display.ts +7 -6
- package/src/state/index.ts +9 -11
- package/src/transfer/clear.ts +29 -6
- package/src/transfer/index.ts +1 -1
- package/src/transfer/transfer.ts +12 -0
- package/src/types.ts +6 -0
- package/src/utils/doc-size.ts +12 -18
- package/src/utils/file-rotation.ts +48 -0
- package/src/utils/firestore-types.ts +82 -0
- package/src/utils/integrity.ts +4 -55
- package/src/utils/logger.ts +3 -29
- package/src/utils/output.ts +11 -35
- package/src/utils/progress.ts +46 -4
- package/src/webhook/index.ts +44 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fazetitans/fscopy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Fast CLI tool to copy Firestore collections between Firebase projects with filtering, parallel transfers, and subcollection support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
"dev": "bun --watch src/cli.ts",
|
|
12
12
|
"test": "bun test",
|
|
13
13
|
"test:watch": "bun test --watch",
|
|
14
|
+
"test:coverage": "bun test --coverage",
|
|
14
15
|
"type-check": "tsc --noEmit",
|
|
15
|
-
"lint": "eslint src/**/*.ts --
|
|
16
|
-
"lint:fix": "eslint src/**/*.ts --
|
|
16
|
+
"lint": "eslint src/**/*.ts --no-warn-ignored",
|
|
17
|
+
"lint:fix": "eslint src/**/*.ts --no-warn-ignored --fix",
|
|
17
18
|
"format": "prettier --write src/**/*.ts",
|
|
18
19
|
"format:check": "prettier --check src/**/*.ts",
|
|
19
20
|
"prepublishOnly": "bun run type-check && bun run lint && bun test"
|
|
@@ -43,7 +44,8 @@
|
|
|
43
44
|
"url": "https://github.com/FaZeTitans/fscopy/issues"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|
|
46
|
-
"node": ">=18.0.0"
|
|
47
|
+
"node": ">=18.0.0",
|
|
48
|
+
"bun": ">=1.0.0"
|
|
47
49
|
},
|
|
48
50
|
"files": [
|
|
49
51
|
"src/**/*.ts",
|
package/src/cli.ts
CHANGED
|
@@ -7,11 +7,11 @@ import yargs from 'yargs';
|
|
|
7
7
|
import { hideBin } from 'yargs/helpers';
|
|
8
8
|
|
|
9
9
|
import pkg from '../package.json';
|
|
10
|
-
import type { Config, CliArgs } from './types.js';
|
|
10
|
+
import type { Config, CliArgs, ValidatedConfig } from './types.js';
|
|
11
11
|
import { Output, parseSize } from './utils/output.js';
|
|
12
12
|
import { ensureCredentials } from './utils/credentials.js';
|
|
13
13
|
import { loadConfigFile, mergeConfig } from './config/parser.js';
|
|
14
|
-
import { validateConfig } from './config/validator.js';
|
|
14
|
+
import { validateConfig, isValidatedConfig } from './config/validator.js';
|
|
15
15
|
import { defaults } from './config/defaults.js';
|
|
16
16
|
import { generateConfigFile } from './config/generator.js';
|
|
17
17
|
import { validateWebhookUrl } from './webhook/index.js';
|
|
@@ -267,9 +267,16 @@ async function main(): Promise<void> {
|
|
|
267
267
|
process.exit(1);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// After validation, config is guaranteed to have required fields
|
|
271
|
+
if (!isValidatedConfig(config)) {
|
|
272
|
+
console.log('\n❌ Configuration validation failed');
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
const validConfig: ValidatedConfig = config;
|
|
276
|
+
|
|
270
277
|
// Validate webhook URL if configured
|
|
271
|
-
if (
|
|
272
|
-
const webhookValidation = validateWebhookUrl(
|
|
278
|
+
if (validConfig.webhook) {
|
|
279
|
+
const webhookValidation = validateWebhookUrl(validConfig.webhook);
|
|
273
280
|
if (!webhookValidation.valid) {
|
|
274
281
|
console.log(`\n❌ ${webhookValidation.warning}`);
|
|
275
282
|
process.exit(1);
|
|
@@ -287,7 +294,7 @@ async function main(): Promise<void> {
|
|
|
287
294
|
|
|
288
295
|
// Skip confirmation in interactive mode (already confirmed by selection)
|
|
289
296
|
if (!argv.yes && !argv.interactive) {
|
|
290
|
-
const confirmed = await askConfirmation(
|
|
297
|
+
const confirmed = await askConfirmation(validConfig);
|
|
291
298
|
if (!confirmed) {
|
|
292
299
|
console.log('\n🚫 Transfer cancelled by user\n');
|
|
293
300
|
process.exit(0);
|
|
@@ -302,10 +309,10 @@ async function main(): Promise<void> {
|
|
|
302
309
|
maxLogSize: parseSize(argv.maxLogSize),
|
|
303
310
|
});
|
|
304
311
|
output.init();
|
|
305
|
-
output.logInfo('Transfer started', { config:
|
|
312
|
+
output.logInfo('Transfer started', { config: validConfig as unknown as Record<string, unknown> });
|
|
306
313
|
|
|
307
314
|
// Run transfer
|
|
308
|
-
const result = await runTransfer(
|
|
315
|
+
const result = await runTransfer(validConfig, argv, output);
|
|
309
316
|
|
|
310
317
|
if (!result.success) {
|
|
311
318
|
process.exit(1);
|
package/src/config/index.ts
CHANGED
|
@@ -11,5 +11,5 @@ export {
|
|
|
11
11
|
loadConfigFile,
|
|
12
12
|
mergeConfig,
|
|
13
13
|
} from './parser.js';
|
|
14
|
-
export { validateConfig } from './validator.js';
|
|
14
|
+
export { validateConfig, isValidatedConfig, assertValidConfig } from './validator.js';
|
|
15
15
|
export { generateConfigFile } from './generator.js';
|
package/src/config/validator.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config } from '../types.js';
|
|
1
|
+
import type { Config, ValidatedConfig } from '../types.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Validate a Firestore collection or document ID.
|
|
@@ -79,3 +79,29 @@ export function validateConfig(config: Config): string[] {
|
|
|
79
79
|
|
|
80
80
|
return errors;
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Type guard to check if a Config has been validated.
|
|
85
|
+
* Returns true if sourceProject and destProject are non-null strings.
|
|
86
|
+
*/
|
|
87
|
+
export function isValidatedConfig(config: Config): config is ValidatedConfig {
|
|
88
|
+
return (
|
|
89
|
+
typeof config.sourceProject === 'string' &&
|
|
90
|
+
typeof config.destProject === 'string' &&
|
|
91
|
+
config.collections.length > 0
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validates config and returns ValidatedConfig if valid, throws otherwise.
|
|
97
|
+
*/
|
|
98
|
+
export function assertValidConfig(config: Config): ValidatedConfig {
|
|
99
|
+
const errors = validateConfig(config);
|
|
100
|
+
if (errors.length > 0) {
|
|
101
|
+
throw new Error(`Invalid configuration: ${errors.join(', ')}`);
|
|
102
|
+
}
|
|
103
|
+
if (!isValidatedConfig(config)) {
|
|
104
|
+
throw new Error('Configuration validation failed');
|
|
105
|
+
}
|
|
106
|
+
return config;
|
|
107
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-wide constants.
|
|
3
|
+
* Centralizes magic numbers for better maintainability.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// Display Constants
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
/** Width for separator lines and progress line clearing */
|
|
11
|
+
export const SEPARATOR_LENGTH = 60;
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Timing Constants
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
/** Interval for logging subcollection/progress updates during scanning (ms) */
|
|
18
|
+
export const PROGRESS_LOG_INTERVAL_MS = 2000;
|
|
19
|
+
|
|
20
|
+
/** Interval for updating speed display in progress bar (ms) */
|
|
21
|
+
export const SPEED_UPDATE_INTERVAL_MS = 500;
|
|
22
|
+
|
|
23
|
+
/** Interval for flushing batched progress bar increments (ms) */
|
|
24
|
+
export const PROGRESS_FLUSH_INTERVAL_MS = 50;
|
|
25
|
+
|
|
26
|
+
/** Default interval for auto-saving transfer state (ms) */
|
|
27
|
+
export const STATE_SAVE_INTERVAL_MS = 5000;
|
|
28
|
+
|
|
29
|
+
/** Default number of batches between state saves */
|
|
30
|
+
export const STATE_SAVE_BATCH_INTERVAL = 10;
|
package/src/interactive.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import admin from 'firebase-admin';
|
|
2
2
|
import type { Firestore } from 'firebase-admin/firestore';
|
|
3
3
|
import { input, checkbox, confirm } from '@inquirer/prompts';
|
|
4
|
+
import { SEPARATOR_LENGTH } from './constants.js';
|
|
4
5
|
import type { Config } from './types.js';
|
|
5
6
|
|
|
6
7
|
async function promptForProject(
|
|
@@ -59,9 +60,9 @@ async function promptForIdModification(
|
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
62
|
-
console.log('\n' + '='.repeat(
|
|
63
|
+
console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
|
|
63
64
|
console.log('🔄 FSCOPY - INTERACTIVE MODE');
|
|
64
|
-
console.log('='.repeat(
|
|
65
|
+
console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
|
|
65
66
|
|
|
66
67
|
const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', '📤');
|
|
67
68
|
const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', '📥');
|
package/src/orchestrator.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { PROGRESS_LOG_INTERVAL_MS, SEPARATOR_LENGTH } from './constants.js';
|
|
4
|
+
import type { Config, ValidatedConfig, Stats, TransferState, TransformFunction, CliArgs, ConflictInfo } from './types.js';
|
|
4
5
|
import { Output } from './utils/output.js';
|
|
5
6
|
import { RateLimiter } from './utils/rate-limiter.js';
|
|
6
7
|
import { ProgressBarWrapper } from './utils/progress.js';
|
|
7
8
|
import { loadTransferState, saveTransferState, createInitialState, validateStateForResume, deleteTransferState, StateSaver } from './state/index.js';
|
|
8
9
|
import { sendWebhook } from './webhook/index.js';
|
|
9
|
-
import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress } from './transfer/index.js';
|
|
10
|
+
import { countDocuments, transferCollection, clearCollection, deleteOrphanDocuments, processInParallel, getDestCollectionPath, type TransferContext, type CountProgress, type DeleteOrphansProgress } from './transfer/index.js';
|
|
10
11
|
import { initializeFirebase, checkDatabaseConnectivity, cleanupFirebase } from './firebase/index.js';
|
|
11
12
|
import { loadTransformFunction } from './transform/loader.js';
|
|
12
13
|
import { printSummary, formatJsonOutput } from './output/display.js';
|
|
@@ -24,7 +25,7 @@ interface ResumeResult {
|
|
|
24
25
|
stats: Stats;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function initializeResumeMode(config:
|
|
28
|
+
function initializeResumeMode(config: ValidatedConfig, output: Output): ResumeResult {
|
|
28
29
|
if (config.resume) {
|
|
29
30
|
const existingState = loadTransferState(config.stateFile);
|
|
30
31
|
if (!existingState) {
|
|
@@ -68,7 +69,7 @@ async function loadTransform(config: Config, output: Output): Promise<TransformF
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
async function handleSuccessOutput(
|
|
71
|
-
config:
|
|
72
|
+
config: ValidatedConfig,
|
|
72
73
|
argv: CliArgs,
|
|
73
74
|
stats: Stats,
|
|
74
75
|
duration: number,
|
|
@@ -83,8 +84,8 @@ async function handleSuccessOutput(
|
|
|
83
84
|
|
|
84
85
|
if (config.webhook) {
|
|
85
86
|
await sendWebhook(config.webhook, {
|
|
86
|
-
source: config.sourceProject
|
|
87
|
-
destination: config.destProject
|
|
87
|
+
source: config.sourceProject,
|
|
88
|
+
destination: config.destProject,
|
|
88
89
|
collections: config.collections,
|
|
89
90
|
stats,
|
|
90
91
|
duration,
|
|
@@ -95,7 +96,7 @@ async function handleSuccessOutput(
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
async function handleErrorOutput(
|
|
98
|
-
config:
|
|
99
|
+
config: ValidatedConfig,
|
|
99
100
|
stats: Stats,
|
|
100
101
|
duration: number,
|
|
101
102
|
errorMessage: string,
|
|
@@ -109,8 +110,8 @@ async function handleErrorOutput(
|
|
|
109
110
|
|
|
110
111
|
if (config.webhook) {
|
|
111
112
|
await sendWebhook(config.webhook, {
|
|
112
|
-
source: config.sourceProject
|
|
113
|
-
destination: config.destProject
|
|
113
|
+
source: config.sourceProject,
|
|
114
|
+
destination: config.destProject,
|
|
114
115
|
collections: config.collections,
|
|
115
116
|
stats,
|
|
116
117
|
duration,
|
|
@@ -121,7 +122,30 @@ async function handleErrorOutput(
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
|
|
125
|
+
function displayTransferOptions(config: Config, rateLimiter: RateLimiter | null, output: Output): void {
|
|
126
|
+
let hasOptions = false;
|
|
127
|
+
|
|
128
|
+
if (rateLimiter) {
|
|
129
|
+
output.info(`⏱️ Rate limiting enabled: ${config.rateLimit} docs/s`);
|
|
130
|
+
hasOptions = true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (config.detectConflicts) {
|
|
134
|
+
output.info('🔒 Conflict detection enabled: Conflicts will be logged but won\'t stop the transfer');
|
|
135
|
+
hasOptions = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (config.maxDepth > 0 && config.includeSubcollections) {
|
|
139
|
+
output.info(`📊 Max depth: ${config.maxDepth} - Subcollections beyond this level will be skipped`);
|
|
140
|
+
hasOptions = true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hasOptions) {
|
|
144
|
+
output.blank();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function runTransfer(config: ValidatedConfig, argv: CliArgs, output: Output): Promise<TransferResult> {
|
|
125
149
|
const startTime = Date.now();
|
|
126
150
|
|
|
127
151
|
try {
|
|
@@ -145,9 +169,7 @@ export async function runTransfer(config: Config, argv: CliArgs, output: Output)
|
|
|
145
169
|
const { progressBar } = await setupProgressTracking(sourceDb, config, currentStats, output);
|
|
146
170
|
|
|
147
171
|
const rateLimiter = config.rateLimit > 0 ? new RateLimiter(config.rateLimit) : null;
|
|
148
|
-
|
|
149
|
-
output.info(`⏱️ Rate limiting enabled: ${config.rateLimit} docs/s\n`);
|
|
150
|
-
}
|
|
172
|
+
displayTransferOptions(config, rateLimiter, output);
|
|
151
173
|
|
|
152
174
|
const stateSaver = transferState ? new StateSaver(config.stateFile, transferState) : null;
|
|
153
175
|
|
|
@@ -264,7 +286,7 @@ async function setupProgressTracking(
|
|
|
264
286
|
onSubcollection: (_path) => {
|
|
265
287
|
subcollectionCount++;
|
|
266
288
|
const now = Date.now();
|
|
267
|
-
if (now - lastSubcollectionLog >
|
|
289
|
+
if (now - lastSubcollectionLog > PROGRESS_LOG_INTERVAL_MS) {
|
|
268
290
|
process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} found)`);
|
|
269
291
|
lastSubcollectionLog = now;
|
|
270
292
|
}
|
|
@@ -276,7 +298,7 @@ async function setupProgressTracking(
|
|
|
276
298
|
}
|
|
277
299
|
|
|
278
300
|
if (subcollectionCount > 0) {
|
|
279
|
-
process.stdout.write('\r' + ' '.repeat(
|
|
301
|
+
process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
|
|
280
302
|
output.info(` Subcollections scanned: ${subcollectionCount}`);
|
|
281
303
|
}
|
|
282
304
|
output.info(` Total: ${totalDocs} documents to transfer\n`);
|
|
@@ -350,16 +372,49 @@ async function deleteOrphanDocs(
|
|
|
350
372
|
output: Output
|
|
351
373
|
): Promise<void> {
|
|
352
374
|
output.info('\n🔄 Deleting orphan documents (sync mode)...');
|
|
375
|
+
|
|
376
|
+
let lastProgressLog = Date.now();
|
|
377
|
+
let subcollectionCount = 0;
|
|
378
|
+
|
|
379
|
+
const progress: DeleteOrphansProgress = {
|
|
380
|
+
onScanStart: (collection) => {
|
|
381
|
+
process.stdout.write(` Scanning ${collection}...`);
|
|
382
|
+
},
|
|
383
|
+
onScanComplete: (collection, orphanCount, totalDest) => {
|
|
384
|
+
process.stdout.write(`\r ${collection}: ${orphanCount}/${totalDest} orphan docs\n`);
|
|
385
|
+
},
|
|
386
|
+
onBatchDeleted: (collection, deletedSoFar, total) => {
|
|
387
|
+
process.stdout.write(`\r Deleting from ${collection}... ${deletedSoFar}/${total}`);
|
|
388
|
+
if (deletedSoFar === total) {
|
|
389
|
+
process.stdout.write('\n');
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
onSubcollectionScan: (_path) => {
|
|
393
|
+
subcollectionCount++;
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
if (now - lastProgressLog > PROGRESS_LOG_INTERVAL_MS) {
|
|
396
|
+
process.stdout.write(`\r Scanning subcollections... (${subcollectionCount} checked)`);
|
|
397
|
+
lastProgressLog = now;
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
|
|
353
402
|
for (const collection of config.collections) {
|
|
354
403
|
const deleted = await deleteOrphanDocuments(
|
|
355
404
|
sourceDb,
|
|
356
405
|
destDb,
|
|
357
406
|
collection,
|
|
358
407
|
config,
|
|
359
|
-
output
|
|
408
|
+
output,
|
|
409
|
+
progress
|
|
360
410
|
);
|
|
361
411
|
stats.documentsDeleted += deleted;
|
|
362
412
|
}
|
|
413
|
+
|
|
414
|
+
if (subcollectionCount > 0) {
|
|
415
|
+
process.stdout.write('\r' + ' '.repeat(SEPARATOR_LENGTH) + '\r');
|
|
416
|
+
}
|
|
417
|
+
|
|
363
418
|
if (stats.documentsDeleted > 0) {
|
|
364
419
|
output.info(` Deleted ${stats.documentsDeleted} orphan documents`);
|
|
365
420
|
} else {
|
package/src/output/display.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
|
+
import { SEPARATOR_LENGTH } from '../constants.js';
|
|
2
3
|
import type { Config, Stats } from '../types.js';
|
|
3
4
|
|
|
4
5
|
function formatIdModification(config: Config): string | null {
|
|
@@ -113,9 +114,9 @@ function displayAdditionalOptions(config: Config): void {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export function displayConfig(config: Config): void {
|
|
116
|
-
console.log('='.repeat(
|
|
117
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
117
118
|
console.log('🔄 FSCOPY - CONFIGURATION');
|
|
118
|
-
console.log('='.repeat(
|
|
119
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
119
120
|
console.log('');
|
|
120
121
|
console.log(` 📤 Source project: ${config.sourceProject || '(not set)'}`);
|
|
121
122
|
console.log(` 📥 Destination project: ${config.destProject || '(not set)'}`);
|
|
@@ -137,7 +138,7 @@ export function displayConfig(config: Config): void {
|
|
|
137
138
|
: ' ⚡ Mode: LIVE (data WILL be transferred)'
|
|
138
139
|
);
|
|
139
140
|
console.log('');
|
|
140
|
-
console.log('='.repeat(
|
|
141
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
export async function askConfirmation(config: Config): Promise<boolean> {
|
|
@@ -162,9 +163,9 @@ export function printSummary(
|
|
|
162
163
|
dryRun?: boolean,
|
|
163
164
|
verifyIntegrity?: boolean
|
|
164
165
|
): void {
|
|
165
|
-
console.log('\n' + '='.repeat(
|
|
166
|
+
console.log('\n' + '='.repeat(SEPARATOR_LENGTH));
|
|
166
167
|
console.log('📊 TRANSFER SUMMARY');
|
|
167
|
-
console.log('='.repeat(
|
|
168
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
168
169
|
console.log(`Collections processed: ${stats.collectionsProcessed}`);
|
|
169
170
|
if (stats.documentsDeleted > 0) {
|
|
170
171
|
console.log(`Documents deleted: ${stats.documentsDeleted}`);
|
|
@@ -193,7 +194,7 @@ export function printSummary(
|
|
|
193
194
|
} else {
|
|
194
195
|
console.log('\n✓ Transfer completed successfully');
|
|
195
196
|
}
|
|
196
|
-
console.log('='.repeat(
|
|
197
|
+
console.log('='.repeat(SEPARATOR_LENGTH) + '\n');
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
export function formatJsonOutput(
|
package/src/state/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
2
|
+
import { STATE_SAVE_INTERVAL_MS, STATE_SAVE_BATCH_INTERVAL } from '../constants.js';
|
|
3
|
+
import type { Config, ValidatedConfig, TransferState, Stats } from '../types.js';
|
|
3
4
|
|
|
4
5
|
export const STATE_VERSION = 1;
|
|
5
6
|
|
|
@@ -81,15 +82,12 @@ export class CompletedDocsCache {
|
|
|
81
82
|
// =============================================================================
|
|
82
83
|
|
|
83
84
|
export interface StateSaverOptions {
|
|
84
|
-
/** Save every N batches (default:
|
|
85
|
+
/** Save every N batches (default: STATE_SAVE_BATCH_INTERVAL) */
|
|
85
86
|
batchInterval?: number;
|
|
86
|
-
/** Save every N milliseconds (default:
|
|
87
|
+
/** Save every N milliseconds (default: STATE_SAVE_INTERVAL_MS) */
|
|
87
88
|
timeInterval?: number;
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
const DEFAULT_BATCH_INTERVAL = 10;
|
|
91
|
-
const DEFAULT_TIME_INTERVAL = 5000;
|
|
92
|
-
|
|
93
91
|
/**
|
|
94
92
|
* Throttled state saver with O(1) completed doc lookups.
|
|
95
93
|
* Uses CompletedDocsCache for efficient lookups during transfer.
|
|
@@ -108,8 +106,8 @@ export class StateSaver {
|
|
|
108
106
|
private readonly state: TransferState,
|
|
109
107
|
options: StateSaverOptions = {}
|
|
110
108
|
) {
|
|
111
|
-
this.batchInterval = options.batchInterval ??
|
|
112
|
-
this.timeInterval = options.timeInterval ??
|
|
109
|
+
this.batchInterval = options.batchInterval ?? STATE_SAVE_BATCH_INTERVAL;
|
|
110
|
+
this.timeInterval = options.timeInterval ?? STATE_SAVE_INTERVAL_MS;
|
|
113
111
|
this.cache = new CompletedDocsCache(state.completedDocs);
|
|
114
112
|
}
|
|
115
113
|
|
|
@@ -244,11 +242,11 @@ export function deleteTransferState(stateFile: string): void {
|
|
|
244
242
|
}
|
|
245
243
|
}
|
|
246
244
|
|
|
247
|
-
export function createInitialState(config:
|
|
245
|
+
export function createInitialState(config: ValidatedConfig): TransferState {
|
|
248
246
|
return {
|
|
249
247
|
version: STATE_VERSION,
|
|
250
|
-
sourceProject: config.sourceProject
|
|
251
|
-
destProject: config.destProject
|
|
248
|
+
sourceProject: config.sourceProject,
|
|
249
|
+
destProject: config.destProject,
|
|
252
250
|
collections: config.collections,
|
|
253
251
|
startedAt: new Date().toISOString(),
|
|
254
252
|
updatedAt: new Date().toISOString(),
|
package/src/transfer/clear.ts
CHANGED
|
@@ -145,13 +145,14 @@ async function deleteOrphanBatch(
|
|
|
145
145
|
return deletedCount;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
async function
|
|
148
|
+
async function processSubcollectionOrphansWithProgress(
|
|
149
149
|
sourceDb: Firestore,
|
|
150
150
|
destDb: Firestore,
|
|
151
151
|
sourceSnapshot: FirebaseFirestore.QuerySnapshot,
|
|
152
152
|
sourceCollectionPath: string,
|
|
153
153
|
config: Config,
|
|
154
|
-
output: Output
|
|
154
|
+
output: Output,
|
|
155
|
+
progress?: DeleteOrphansProgress
|
|
155
156
|
): Promise<number> {
|
|
156
157
|
let deletedCount = 0;
|
|
157
158
|
|
|
@@ -161,28 +162,48 @@ async function processSubcollectionOrphans(
|
|
|
161
162
|
if (matchesExcludePattern(subId, config.exclude)) continue;
|
|
162
163
|
|
|
163
164
|
const subPath = `${sourceCollectionPath}/${sourceDoc.id}/${subId}`;
|
|
164
|
-
|
|
165
|
+
progress?.onSubcollectionScan?.(subPath);
|
|
166
|
+
deletedCount += await deleteOrphanDocuments(
|
|
167
|
+
sourceDb,
|
|
168
|
+
destDb,
|
|
169
|
+
subPath,
|
|
170
|
+
config,
|
|
171
|
+
output,
|
|
172
|
+
progress
|
|
173
|
+
);
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
return deletedCount;
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
export interface DeleteOrphansProgress {
|
|
181
|
+
onScanStart?: (collection: string) => void;
|
|
182
|
+
onScanComplete?: (collection: string, orphanCount: number, totalDest: number) => void;
|
|
183
|
+
onBatchDeleted?: (collection: string, deletedSoFar: number, total: number) => void;
|
|
184
|
+
onSubcollectionScan?: (path: string) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
171
187
|
export async function deleteOrphanDocuments(
|
|
172
188
|
sourceDb: Firestore,
|
|
173
189
|
destDb: Firestore,
|
|
174
190
|
sourceCollectionPath: string,
|
|
175
191
|
config: Config,
|
|
176
|
-
output: Output
|
|
192
|
+
output: Output,
|
|
193
|
+
progress?: DeleteOrphansProgress
|
|
177
194
|
): Promise<number> {
|
|
178
195
|
const destCollectionPath = getDestCollectionPath(sourceCollectionPath, config.renameCollection);
|
|
179
196
|
|
|
197
|
+
progress?.onScanStart?.(destCollectionPath);
|
|
198
|
+
|
|
180
199
|
const sourceSnapshot = await sourceDb.collection(sourceCollectionPath).select().get();
|
|
181
200
|
const sourceIds = new Set(sourceSnapshot.docs.map((doc) => doc.id));
|
|
182
201
|
|
|
183
202
|
const destSnapshot = await destDb.collection(destCollectionPath).select().get();
|
|
184
203
|
const orphanDocs = destSnapshot.docs.filter((doc) => !sourceIds.has(doc.id));
|
|
185
204
|
|
|
205
|
+
progress?.onScanComplete?.(destCollectionPath, orphanDocs.length, destSnapshot.size);
|
|
206
|
+
|
|
186
207
|
let deletedCount = 0;
|
|
187
208
|
|
|
188
209
|
if (orphanDocs.length > 0) {
|
|
@@ -197,17 +218,19 @@ export async function deleteOrphanDocuments(
|
|
|
197
218
|
config,
|
|
198
219
|
output
|
|
199
220
|
);
|
|
221
|
+
progress?.onBatchDeleted?.(destCollectionPath, deletedCount, orphanDocs.length);
|
|
200
222
|
}
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
if (config.includeSubcollections) {
|
|
204
|
-
deletedCount += await
|
|
226
|
+
deletedCount += await processSubcollectionOrphansWithProgress(
|
|
205
227
|
sourceDb,
|
|
206
228
|
destDb,
|
|
207
229
|
sourceSnapshot,
|
|
208
230
|
sourceCollectionPath,
|
|
209
231
|
config,
|
|
210
|
-
output
|
|
232
|
+
output,
|
|
233
|
+
progress
|
|
211
234
|
);
|
|
212
235
|
}
|
|
213
236
|
|
package/src/transfer/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { getSubcollections, getDestCollectionPath, getDestDocId } from './helpers.js';
|
|
2
2
|
export { processInParallel, type ParallelResult } from './parallel.js';
|
|
3
3
|
export { countDocuments, type CountProgress } from './count.js';
|
|
4
|
-
export { clearCollection, deleteOrphanDocuments } from './clear.js';
|
|
4
|
+
export { clearCollection, deleteOrphanDocuments, type DeleteOrphansProgress } from './clear.js';
|
|
5
5
|
export { transferCollection, type TransferContext } from './transfer.js';
|
package/src/transfer/transfer.ts
CHANGED
|
@@ -186,6 +186,9 @@ function checkDocumentSize(
|
|
|
186
186
|
);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// Track which collections have already shown the max-depth warning (to avoid spam)
|
|
190
|
+
const maxDepthWarningsShown = new Set<string>();
|
|
191
|
+
|
|
189
192
|
async function processSubcollections(
|
|
190
193
|
ctx: TransferContext,
|
|
191
194
|
doc: QueryDocumentSnapshot,
|
|
@@ -196,6 +199,15 @@ async function processSubcollections(
|
|
|
196
199
|
|
|
197
200
|
// Check max depth limit (0 = unlimited)
|
|
198
201
|
if (config.maxDepth > 0 && depth >= config.maxDepth) {
|
|
202
|
+
// Show console warning only once per root collection
|
|
203
|
+
const rootCollection = collectionPath.split('/')[0];
|
|
204
|
+
if (!maxDepthWarningsShown.has(rootCollection)) {
|
|
205
|
+
maxDepthWarningsShown.add(rootCollection);
|
|
206
|
+
output.warn(
|
|
207
|
+
`⚠️ Subcollections in ${rootCollection} beyond depth ${config.maxDepth} will be skipped`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
199
211
|
output.logInfo(`Skipping subcollections at depth ${depth} (max: ${config.maxDepth})`, {
|
|
200
212
|
collection: collectionPath,
|
|
201
213
|
docId: doc.id,
|
package/src/types.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface Config {
|
|
|
40
40
|
verifyIntegrity: boolean;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Config after validation - required fields are guaranteed non-null
|
|
44
|
+
export interface ValidatedConfig extends Omit<Config, 'sourceProject' | 'destProject'> {
|
|
45
|
+
sourceProject: string;
|
|
46
|
+
destProject: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export interface ConflictInfo {
|
|
44
50
|
collection: string;
|
|
45
51
|
docId: string;
|
package/src/utils/doc-size.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isTimestamp,
|
|
3
|
+
isGeoPoint,
|
|
4
|
+
isDocumentReference,
|
|
5
|
+
getDocumentReferencePath,
|
|
6
|
+
} from './firestore-types.js';
|
|
7
|
+
|
|
1
8
|
/**
|
|
2
9
|
* Firestore maximum document size in bytes (1 MiB)
|
|
3
10
|
*/
|
|
@@ -31,24 +38,11 @@ export function estimateDocumentSize(data: Record<string, unknown>, docPath?: st
|
|
|
31
38
|
return size;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
function isGeoPoint(value: object): boolean {
|
|
39
|
-
return '_latitude' in value && '_longitude' in value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isDocumentReference(value: object): boolean {
|
|
43
|
-
return '_path' in value && typeof (value as { _path: unknown })._path === 'object';
|
|
44
|
-
}
|
|
45
|
-
|
|
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;
|
|
41
|
+
function getDocRefSize(value: unknown): number {
|
|
42
|
+
if (isDocumentReference(value)) {
|
|
43
|
+
return getDocumentReferencePath(value).length + 1;
|
|
50
44
|
}
|
|
51
|
-
return 16; // Approximate
|
|
45
|
+
return 16; // Approximate for unknown reference format
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
function estimateArraySize(arr: unknown[]): number {
|
|
@@ -76,7 +70,7 @@ function estimateValueSize(value: unknown): number {
|
|
|
76
70
|
if (value instanceof Date) return 8;
|
|
77
71
|
|
|
78
72
|
if (typeof value === 'object') {
|
|
79
|
-
if (
|
|
73
|
+
if (isTimestamp(value)) return 8;
|
|
80
74
|
if (isGeoPoint(value)) return 16;
|
|
81
75
|
if (isDocumentReference(value)) return getDocRefSize(value);
|
|
82
76
|
if (Array.isArray(value)) return estimateArraySize(value);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rotate a file if it exceeds maxSize.
|
|
6
|
+
* Creates numbered backups: file.1.ext, file.2.ext, etc.
|
|
7
|
+
*
|
|
8
|
+
* @param filePath - Path to the file to rotate
|
|
9
|
+
* @param maxSize - Maximum file size in bytes (0 = no rotation)
|
|
10
|
+
* @param maxFiles - Maximum number of rotated files to keep (default: 5)
|
|
11
|
+
* @returns true if rotation occurred, false otherwise
|
|
12
|
+
*/
|
|
13
|
+
export function rotateFileIfNeeded(
|
|
14
|
+
filePath: string,
|
|
15
|
+
maxSize: number,
|
|
16
|
+
maxFiles: number = 5
|
|
17
|
+
): boolean {
|
|
18
|
+
if (!filePath || maxSize <= 0) return false;
|
|
19
|
+
if (!fs.existsSync(filePath)) return false;
|
|
20
|
+
|
|
21
|
+
const stats = fs.statSync(filePath);
|
|
22
|
+
if (stats.size < maxSize) return false;
|
|
23
|
+
|
|
24
|
+
const dir = path.dirname(filePath);
|
|
25
|
+
const ext = path.extname(filePath);
|
|
26
|
+
const base = path.basename(filePath, ext);
|
|
27
|
+
|
|
28
|
+
// Delete oldest backup if at max
|
|
29
|
+
const oldestPath = path.join(dir, `${base}.${maxFiles}${ext}`);
|
|
30
|
+
if (fs.existsSync(oldestPath)) {
|
|
31
|
+
fs.unlinkSync(oldestPath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Shift existing backups: .4 -> .5, .3 -> .4, etc.
|
|
35
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
36
|
+
const from = path.join(dir, `${base}.${i}${ext}`);
|
|
37
|
+
const to = path.join(dir, `${base}.${i + 1}${ext}`);
|
|
38
|
+
if (fs.existsSync(from)) {
|
|
39
|
+
fs.renameSync(from, to);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Rename current to .1
|
|
44
|
+
const backupPath = path.join(dir, `${base}.1${ext}`);
|
|
45
|
+
fs.renameSync(filePath, backupPath);
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guards for Firestore special types.
|
|
3
|
+
*
|
|
4
|
+
* Firestore SDK exposes these types with public properties:
|
|
5
|
+
* - Timestamp: { seconds: number, nanoseconds: number, toDate(), toMillis() }
|
|
6
|
+
* - GeoPoint: { latitude: number, longitude: number, isEqual() }
|
|
7
|
+
* - DocumentReference: { path: string, id: string, parent, ... }
|
|
8
|
+
*
|
|
9
|
+
* Note: Internal properties like _seconds, _path may exist but are not guaranteed.
|
|
10
|
+
* We use public API properties for reliability.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface FirestoreTimestamp {
|
|
14
|
+
seconds: number;
|
|
15
|
+
nanoseconds: number;
|
|
16
|
+
toDate?: () => Date;
|
|
17
|
+
toMillis?: () => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FirestoreGeoPoint {
|
|
21
|
+
latitude: number;
|
|
22
|
+
longitude: number;
|
|
23
|
+
isEqual?: (other: unknown) => boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FirestoreDocumentReference {
|
|
27
|
+
path: string;
|
|
28
|
+
id: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if value is a Firestore Timestamp.
|
|
33
|
+
* Works with both SDK instances and plain objects with same shape.
|
|
34
|
+
*/
|
|
35
|
+
export function isTimestamp(value: unknown): value is FirestoreTimestamp {
|
|
36
|
+
return (
|
|
37
|
+
typeof value === 'object' &&
|
|
38
|
+
value !== null &&
|
|
39
|
+
'seconds' in value &&
|
|
40
|
+
'nanoseconds' in value &&
|
|
41
|
+
typeof (value as FirestoreTimestamp).seconds === 'number' &&
|
|
42
|
+
typeof (value as FirestoreTimestamp).nanoseconds === 'number'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if value is a Firestore GeoPoint.
|
|
48
|
+
* Excludes Timestamps which also have numeric properties.
|
|
49
|
+
*/
|
|
50
|
+
export function isGeoPoint(value: unknown): value is FirestoreGeoPoint {
|
|
51
|
+
return (
|
|
52
|
+
typeof value === 'object' &&
|
|
53
|
+
value !== null &&
|
|
54
|
+
'latitude' in value &&
|
|
55
|
+
'longitude' in value &&
|
|
56
|
+
typeof (value as FirestoreGeoPoint).latitude === 'number' &&
|
|
57
|
+
typeof (value as FirestoreGeoPoint).longitude === 'number' &&
|
|
58
|
+
!('seconds' in value) // Distinguish from Timestamp
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if value is a Firestore DocumentReference.
|
|
64
|
+
*/
|
|
65
|
+
export function isDocumentReference(value: unknown): value is FirestoreDocumentReference {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === 'object' &&
|
|
68
|
+
value !== null &&
|
|
69
|
+
'path' in value &&
|
|
70
|
+
'id' in value &&
|
|
71
|
+
typeof (value as FirestoreDocumentReference).path === 'string' &&
|
|
72
|
+
typeof (value as FirestoreDocumentReference).id === 'string'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the path from a DocumentReference.
|
|
78
|
+
* Handles both SDK instances and plain objects.
|
|
79
|
+
*/
|
|
80
|
+
export function getDocumentReferencePath(ref: FirestoreDocumentReference): string {
|
|
81
|
+
return ref.path;
|
|
82
|
+
}
|
package/src/utils/integrity.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isTimestamp, isGeoPoint, isDocumentReference } from './firestore-types.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Compute a SHA-256 hash of document data.
|
|
@@ -28,17 +29,17 @@ function serializeForHash(value: unknown): string {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// Handle Firestore Timestamp
|
|
31
|
-
if (
|
|
32
|
+
if (isTimestamp(value)) {
|
|
32
33
|
return JSON.stringify({ _seconds: value.seconds, _nanoseconds: value.nanoseconds });
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
// Handle Firestore GeoPoint
|
|
36
|
-
if (
|
|
37
|
+
if (isGeoPoint(value)) {
|
|
37
38
|
return JSON.stringify({ _latitude: value.latitude, _longitude: value.longitude });
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
// Handle Firestore DocumentReference
|
|
41
|
-
if (
|
|
42
|
+
if (isDocumentReference(value)) {
|
|
42
43
|
return JSON.stringify({ _path: value.path });
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -68,55 +69,3 @@ function serializeForHash(value: unknown): string {
|
|
|
68
69
|
export function compareHashes(sourceHash: string, destHash: string): boolean {
|
|
69
70
|
return sourceHash === destHash;
|
|
70
71
|
}
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import type { Stats, LogEntry } from '../types.js';
|
|
3
|
+
import { rotateFileIfNeeded } from './file-rotation.js';
|
|
4
4
|
|
|
5
5
|
export interface LoggerOptions {
|
|
6
6
|
logPath?: string;
|
|
@@ -69,35 +69,9 @@ export class Logger {
|
|
|
69
69
|
* Creates numbered backups: log.1, log.2, etc.
|
|
70
70
|
*/
|
|
71
71
|
private rotateIfNeeded(): void {
|
|
72
|
-
if (
|
|
73
|
-
|
|
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
|
-
}
|
|
72
|
+
if (this.logPath) {
|
|
73
|
+
rotateFileIfNeeded(this.logPath, this.maxSize, this.maxFiles);
|
|
96
74
|
}
|
|
97
|
-
|
|
98
|
-
// Rename current to .1
|
|
99
|
-
const backupPath = path.join(dir, `${base}.1${ext}`);
|
|
100
|
-
fs.renameSync(this.logPath, backupPath);
|
|
101
75
|
}
|
|
102
76
|
|
|
103
77
|
summary(stats: Stats, duration: string): void {
|
package/src/utils/output.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import
|
|
2
|
+
import { SEPARATOR_LENGTH } from '../constants.js';
|
|
3
3
|
import type { Stats, LogEntry } from '../types.js';
|
|
4
|
+
import { rotateFileIfNeeded } from './file-rotation.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Parse a size string like "10MB" or "1GB" into bytes.
|
|
@@ -72,38 +73,13 @@ export class Output {
|
|
|
72
73
|
* Creates numbered backups: log.1.ext, log.2.ext, etc.
|
|
73
74
|
*/
|
|
74
75
|
private rotateLogIfNeeded(): void {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const stats = fs.statSync(logFile);
|
|
83
|
-
if (stats.size < maxSize) return;
|
|
84
|
-
|
|
85
|
-
const dir = path.dirname(logFile);
|
|
86
|
-
const ext = path.extname(logFile);
|
|
87
|
-
const base = path.basename(logFile, ext);
|
|
88
|
-
|
|
89
|
-
// Delete oldest backup if at max
|
|
90
|
-
const oldestPath = path.join(dir, `${base}.${maxFiles}${ext}`);
|
|
91
|
-
if (fs.existsSync(oldestPath)) {
|
|
92
|
-
fs.unlinkSync(oldestPath);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Shift existing backups: .4 -> .5, .3 -> .4, etc.
|
|
96
|
-
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
97
|
-
const from = path.join(dir, `${base}.${i}${ext}`);
|
|
98
|
-
const to = path.join(dir, `${base}.${i + 1}${ext}`);
|
|
99
|
-
if (fs.existsSync(from)) {
|
|
100
|
-
fs.renameSync(from, to);
|
|
101
|
-
}
|
|
76
|
+
if (this.options.logFile) {
|
|
77
|
+
rotateFileIfNeeded(
|
|
78
|
+
this.options.logFile,
|
|
79
|
+
this.options.maxLogSize ?? 0,
|
|
80
|
+
this.options.maxLogFiles ?? 5
|
|
81
|
+
);
|
|
102
82
|
}
|
|
103
|
-
|
|
104
|
-
// Rename current to .1
|
|
105
|
-
const backupPath = path.join(dir, `${base}.1${ext}`);
|
|
106
|
-
fs.renameSync(logFile, backupPath);
|
|
107
83
|
}
|
|
108
84
|
|
|
109
85
|
// ==========================================================================
|
|
@@ -156,7 +132,7 @@ export class Output {
|
|
|
156
132
|
}
|
|
157
133
|
|
|
158
134
|
/** Print a separator line */
|
|
159
|
-
separator(char: string = '=', length: number =
|
|
135
|
+
separator(char: string = '=', length: number = SEPARATOR_LENGTH): void {
|
|
160
136
|
if (!this.options.quiet && !this.options.json) {
|
|
161
137
|
console.log(char.repeat(length));
|
|
162
138
|
}
|
|
@@ -165,9 +141,9 @@ export class Output {
|
|
|
165
141
|
/** Print a header with separators */
|
|
166
142
|
header(title: string): void {
|
|
167
143
|
if (!this.options.quiet && !this.options.json) {
|
|
168
|
-
console.log('='.repeat(
|
|
144
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
169
145
|
console.log(title);
|
|
170
|
-
console.log('='.repeat(
|
|
146
|
+
console.log('='.repeat(SEPARATOR_LENGTH));
|
|
171
147
|
}
|
|
172
148
|
}
|
|
173
149
|
|
package/src/utils/progress.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import cliProgress from 'cli-progress';
|
|
2
|
+
import { SPEED_UPDATE_INTERVAL_MS, PROGRESS_FLUSH_INTERVAL_MS } from '../constants.js';
|
|
2
3
|
import type { Stats } from '../types.js';
|
|
3
4
|
|
|
4
5
|
export interface ProgressBarOptions {
|
|
@@ -17,14 +18,18 @@ const DEFAULT_OPTIONS: ProgressBarOptions = {
|
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Wrapper around cli-progress that handles speed calculation and cleanup.
|
|
20
|
-
*
|
|
21
|
+
* Thread-safe for parallel mode: uses batched updates to prevent UI flickering.
|
|
21
22
|
*/
|
|
22
23
|
export class ProgressBarWrapper {
|
|
23
24
|
private bar: cliProgress.SingleBar | null = null;
|
|
24
25
|
private speedInterval: NodeJS.Timeout | null = null;
|
|
26
|
+
private flushInterval: NodeJS.Timeout | null = null;
|
|
25
27
|
private lastDocsTransferred = 0;
|
|
26
28
|
private lastTime = Date.now();
|
|
27
29
|
|
|
30
|
+
// Batched increment counter for parallel-safe updates
|
|
31
|
+
private pendingIncrements = 0;
|
|
32
|
+
|
|
28
33
|
constructor(private readonly options: ProgressBarOptions = {}) {}
|
|
29
34
|
|
|
30
35
|
/**
|
|
@@ -45,21 +50,50 @@ export class ProgressBarWrapper {
|
|
|
45
50
|
this.bar.start(total, 0, { speed: '0' });
|
|
46
51
|
this.lastDocsTransferred = 0;
|
|
47
52
|
this.lastTime = Date.now();
|
|
53
|
+
this.pendingIncrements = 0;
|
|
48
54
|
|
|
55
|
+
// Speed update interval
|
|
49
56
|
this.speedInterval = setInterval(() => {
|
|
50
57
|
this.updateSpeed(stats);
|
|
51
|
-
},
|
|
58
|
+
}, SPEED_UPDATE_INTERVAL_MS);
|
|
59
|
+
|
|
60
|
+
// Batched increment flush interval (for parallel mode)
|
|
61
|
+
this.flushInterval = setInterval(() => {
|
|
62
|
+
this.flushIncrements();
|
|
63
|
+
}, PROGRESS_FLUSH_INTERVAL_MS);
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
55
67
|
* Increment the progress bar by 1.
|
|
68
|
+
* Thread-safe: increments are batched and flushed periodically.
|
|
56
69
|
*/
|
|
57
70
|
increment(): void {
|
|
58
71
|
if (this.bar) {
|
|
59
|
-
this.
|
|
72
|
+
this.pendingIncrements++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Increment the progress bar by a specific amount.
|
|
78
|
+
* Thread-safe: increments are batched and flushed periodically.
|
|
79
|
+
*/
|
|
80
|
+
incrementBy(count: number): void {
|
|
81
|
+
if (this.bar && count > 0) {
|
|
82
|
+
this.pendingIncrements += count;
|
|
60
83
|
}
|
|
61
84
|
}
|
|
62
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Flush pending increments to the progress bar.
|
|
88
|
+
*/
|
|
89
|
+
private flushIncrements(): void {
|
|
90
|
+
if (!this.bar || this.pendingIncrements === 0) return;
|
|
91
|
+
|
|
92
|
+
const toFlush = this.pendingIncrements;
|
|
93
|
+
this.pendingIncrements = 0;
|
|
94
|
+
this.bar.increment(toFlush);
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
/**
|
|
64
98
|
* Update the speed display based on current stats.
|
|
65
99
|
*/
|
|
@@ -80,9 +114,17 @@ export class ProgressBarWrapper {
|
|
|
80
114
|
}
|
|
81
115
|
|
|
82
116
|
/**
|
|
83
|
-
* Stop the progress bar and clean up
|
|
117
|
+
* Stop the progress bar and clean up intervals.
|
|
118
|
+
* Flushes any pending increments before stopping.
|
|
84
119
|
*/
|
|
85
120
|
stop(): void {
|
|
121
|
+
// Flush remaining increments
|
|
122
|
+
this.flushIncrements();
|
|
123
|
+
|
|
124
|
+
if (this.flushInterval) {
|
|
125
|
+
clearInterval(this.flushInterval);
|
|
126
|
+
this.flushInterval = null;
|
|
127
|
+
}
|
|
86
128
|
if (this.speedInterval) {
|
|
87
129
|
clearInterval(this.speedInterval);
|
|
88
130
|
this.speedInterval = null;
|
package/src/webhook/index.ts
CHANGED
|
@@ -125,22 +125,62 @@ export async function sendWebhook(
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
try {
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
130
|
+
|
|
128
131
|
const response = await fetch(webhookUrl, {
|
|
129
132
|
method: 'POST',
|
|
130
133
|
headers: { 'Content-Type': 'application/json' },
|
|
131
134
|
body: JSON.stringify(body),
|
|
135
|
+
signal: controller.signal,
|
|
132
136
|
});
|
|
133
137
|
|
|
138
|
+
clearTimeout(timeout);
|
|
139
|
+
|
|
134
140
|
if (!response.ok) {
|
|
135
141
|
const errorText = await response.text();
|
|
136
|
-
|
|
142
|
+
const statusCode = response.status;
|
|
143
|
+
|
|
144
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
145
|
+
// Client error - likely bad URL or payload format
|
|
146
|
+
output.logError(`Webhook client error (${statusCode})`, {
|
|
147
|
+
url: webhookUrl,
|
|
148
|
+
status: statusCode,
|
|
149
|
+
error: errorText,
|
|
150
|
+
});
|
|
151
|
+
output.warn(
|
|
152
|
+
`⚠️ Webhook failed (HTTP ${statusCode}): Check webhook URL or payload format`
|
|
153
|
+
);
|
|
154
|
+
} else if (statusCode >= 500) {
|
|
155
|
+
// Server error - retry might help
|
|
156
|
+
output.logError(`Webhook server error (${statusCode})`, {
|
|
157
|
+
url: webhookUrl,
|
|
158
|
+
status: statusCode,
|
|
159
|
+
error: errorText,
|
|
160
|
+
});
|
|
161
|
+
output.warn(
|
|
162
|
+
`⚠️ Webhook server error (HTTP ${statusCode}): The webhook service may be temporarily unavailable`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
|
|
140
169
|
output.info(`📤 Webhook notification sent (${webhookType})`);
|
|
141
170
|
} catch (error) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
171
|
+
const err = error as Error;
|
|
172
|
+
|
|
173
|
+
if (err.name === 'AbortError') {
|
|
174
|
+
output.logError('Webhook timeout after 30s', { url: webhookUrl });
|
|
175
|
+
output.warn('⚠️ Webhook request timed out after 30 seconds');
|
|
176
|
+
} else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
|
|
177
|
+
output.logError(`Webhook connection failed: ${err.message}`, { url: webhookUrl });
|
|
178
|
+
output.warn(
|
|
179
|
+
`⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
output.logError(`Failed to send webhook: ${err.message}`, { url: webhookUrl });
|
|
183
|
+
output.warn(`⚠️ Failed to send webhook: ${err.message}`);
|
|
184
|
+
}
|
|
145
185
|
}
|
|
146
186
|
}
|