@fazetitans/fscopy 1.1.2 ā 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 +86 -33
- package/package.json +3 -3
- package/src/cli.ts +82 -620
- 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/patterns.ts +3 -2
- package/src/utils/progress.ts +102 -0
- package/src/utils/rate-limiter.ts +4 -2
- package/src/webhook/index.ts +24 -6
package/src/config/defaults.ts
CHANGED
package/src/config/parser.ts
CHANGED
|
@@ -257,5 +257,9 @@ export function mergeConfig(
|
|
|
257
257
|
rateLimit: cliArgs.rateLimit ?? defaultConfig.rateLimit,
|
|
258
258
|
skipOversized: cliArgs.skipOversized ?? defaultConfig.skipOversized,
|
|
259
259
|
json: cliArgs.json ?? defaultConfig.json,
|
|
260
|
+
transformSamples: cliArgs.transformSamples ?? defaultConfig.transformSamples,
|
|
261
|
+
detectConflicts: cliArgs.detectConflicts ?? defaultConfig.detectConflicts,
|
|
262
|
+
maxDepth: cliArgs.maxDepth ?? defaultConfig.maxDepth,
|
|
263
|
+
verifyIntegrity: cliArgs.verifyIntegrity ?? defaultConfig.verifyIntegrity,
|
|
260
264
|
};
|
|
261
265
|
}
|
package/src/config/validator.ts
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
import type { Config } from '../types.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Validate a Firestore collection or document ID.
|
|
5
|
+
* Returns an error message if invalid, null if valid.
|
|
6
|
+
*/
|
|
7
|
+
export function validateFirestoreId(
|
|
8
|
+
id: string,
|
|
9
|
+
type: 'collection' | 'document' = 'collection'
|
|
10
|
+
): string | null {
|
|
11
|
+
// Cannot be empty
|
|
12
|
+
if (!id || id.length === 0) {
|
|
13
|
+
return `${type} name cannot be empty`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Cannot be a lone period or double period
|
|
17
|
+
if (id === '.' || id === '..') {
|
|
18
|
+
return `${type} name cannot be '.' or '..'`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cannot match __.*__ pattern (reserved by Firestore)
|
|
22
|
+
if (/^__.*__$/.test(id)) {
|
|
23
|
+
return `${type} name cannot match pattern '__*__' (reserved by Firestore)`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validate a collection path (may contain nested paths like users/123/orders).
|
|
31
|
+
* Returns array of error messages, empty if valid.
|
|
32
|
+
*/
|
|
33
|
+
export function validateCollectionPath(path: string): string[] {
|
|
34
|
+
const errors: string[] = [];
|
|
35
|
+
const segments = path.split('/');
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < segments.length; i++) {
|
|
38
|
+
const segment = segments[i];
|
|
39
|
+
const type = i % 2 === 0 ? 'collection' : 'document';
|
|
40
|
+
const error = validateFirestoreId(segment, type);
|
|
41
|
+
if (error) {
|
|
42
|
+
errors.push(`Invalid ${type} in path "${path}": ${error}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return errors;
|
|
47
|
+
}
|
|
48
|
+
|
|
3
49
|
export function validateConfig(config: Config): string[] {
|
|
4
50
|
const errors: string[] = [];
|
|
5
51
|
|
|
@@ -25,5 +71,11 @@ export function validateConfig(config: Config): string[] {
|
|
|
25
71
|
errors.push('At least one collection is required (-c or --collections)');
|
|
26
72
|
}
|
|
27
73
|
|
|
74
|
+
// Validate collection names
|
|
75
|
+
for (const collection of config.collections) {
|
|
76
|
+
const pathErrors = validateCollectionPath(collection);
|
|
77
|
+
errors.push(...pathErrors);
|
|
78
|
+
}
|
|
79
|
+
|
|
28
80
|
return errors;
|
|
29
81
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import admin from 'firebase-admin';
|
|
2
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
3
|
+
import type { Config } from '../types.js';
|
|
4
|
+
import type { Output } from '../utils/output.js';
|
|
5
|
+
import { formatFirebaseError } from '../utils/errors.js';
|
|
6
|
+
|
|
7
|
+
let sourceApp: admin.app.App | null = null;
|
|
8
|
+
let destApp: admin.app.App | null = null;
|
|
9
|
+
|
|
10
|
+
export interface FirebaseConnections {
|
|
11
|
+
sourceDb: Firestore;
|
|
12
|
+
destDb: Firestore;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function initializeFirebase(config: Config): FirebaseConnections {
|
|
16
|
+
sourceApp = admin.initializeApp(
|
|
17
|
+
{
|
|
18
|
+
credential: admin.credential.applicationDefault(),
|
|
19
|
+
projectId: config.sourceProject!,
|
|
20
|
+
},
|
|
21
|
+
'source'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
destApp = admin.initializeApp(
|
|
25
|
+
{
|
|
26
|
+
credential: admin.credential.applicationDefault(),
|
|
27
|
+
projectId: config.destProject!,
|
|
28
|
+
},
|
|
29
|
+
'dest'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
sourceDb: sourceApp.firestore(),
|
|
34
|
+
destDb: destApp.firestore(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function checkDatabaseConnectivity(
|
|
39
|
+
sourceDb: Firestore,
|
|
40
|
+
destDb: Firestore,
|
|
41
|
+
config: Config,
|
|
42
|
+
output: Output
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
output.info('š Checking database connectivity...');
|
|
45
|
+
|
|
46
|
+
// Check source database
|
|
47
|
+
try {
|
|
48
|
+
await sourceDb.listCollections();
|
|
49
|
+
output.info(` ā Source (${config.sourceProject}) - connected`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
const err = error as Error & { code?: string };
|
|
52
|
+
const errorInfo = formatFirebaseError(err);
|
|
53
|
+
const hint = errorInfo.suggestion ? `\n Hint: ${errorInfo.suggestion}` : '';
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Cannot connect to source database (${config.sourceProject}): ${errorInfo.message}${hint}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check destination database (only if different from source)
|
|
60
|
+
if (config.sourceProject !== config.destProject) {
|
|
61
|
+
try {
|
|
62
|
+
await destDb.listCollections();
|
|
63
|
+
output.info(` ā Destination (${config.destProject}) - connected`);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const err = error as Error & { code?: string };
|
|
66
|
+
const errorInfo = formatFirebaseError(err);
|
|
67
|
+
const hint = errorInfo.suggestion ? `\n Hint: ${errorInfo.suggestion}` : '';
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Cannot connect to destination database (${config.destProject}): ${errorInfo.message}${hint}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
output.info(` ā Destination (same as source) - connected`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
output.blank();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function cleanupFirebase(): Promise<void> {
|
|
80
|
+
if (sourceApp) await sourceApp.delete();
|
|
81
|
+
if (destApp) await destApp.delete();
|
|
82
|
+
}
|
package/src/interactive.ts
CHANGED
|
@@ -3,72 +3,76 @@ import type { Firestore } from 'firebase-admin/firestore';
|
|
|
3
3
|
import { input, checkbox, confirm } from '@inquirer/prompts';
|
|
4
4
|
import type { Config } from './types.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
async function promptForProject(
|
|
7
|
+
currentValue: string | null | undefined,
|
|
8
|
+
label: string,
|
|
9
|
+
emoji: string
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
if (currentValue) {
|
|
12
|
+
console.log(`${emoji} ${label}: ${currentValue}`);
|
|
13
|
+
return currentValue;
|
|
14
|
+
}
|
|
15
|
+
return input({
|
|
16
|
+
message: `${label}:`,
|
|
17
|
+
validate: (value) => value.length > 0 || 'Project ID is required',
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function promptForIdModification(
|
|
22
|
+
currentPrefix: string | null,
|
|
23
|
+
currentSuffix: string | null
|
|
24
|
+
): Promise<{ idPrefix: string | null; idSuffix: string | null }> {
|
|
25
|
+
console.log('\nā ļø Source and destination are the same project.');
|
|
26
|
+
console.log(' You need to rename collections or modify document IDs to avoid overwriting.\n');
|
|
27
|
+
|
|
28
|
+
const modifyIds = await confirm({
|
|
29
|
+
message: 'Add a prefix to document IDs?',
|
|
30
|
+
default: true,
|
|
31
|
+
});
|
|
10
32
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
validate: (value) => value.length > 0 || 'Project ID is required',
|
|
33
|
+
if (modifyIds) {
|
|
34
|
+
const idPrefix = await input({
|
|
35
|
+
message: 'Document ID prefix (e.g., "backup_"):',
|
|
36
|
+
default: 'backup_',
|
|
37
|
+
validate: (value) => value.length > 0 || 'Prefix is required',
|
|
17
38
|
});
|
|
18
|
-
|
|
19
|
-
console.log(`š¤ Source project: ${sourceProject}`);
|
|
39
|
+
return { idPrefix, idSuffix: currentSuffix };
|
|
20
40
|
}
|
|
21
41
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
const useSuffix = await confirm({
|
|
43
|
+
message: 'Add a suffix to document IDs instead?',
|
|
44
|
+
default: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (useSuffix) {
|
|
48
|
+
const idSuffix = await input({
|
|
49
|
+
message: 'Document ID suffix (e.g., "_backup"):',
|
|
50
|
+
default: '_backup',
|
|
51
|
+
validate: (value) => value.length > 0 || 'Suffix is required',
|
|
28
52
|
});
|
|
29
|
-
|
|
30
|
-
console.log(`š„ Destination project: ${destProject}`);
|
|
53
|
+
return { idPrefix: currentPrefix, idSuffix };
|
|
31
54
|
}
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
|
|
56
|
+
console.log('\nā Cannot proceed: source and destination are the same without ID modification.');
|
|
57
|
+
console.log(' This would overwrite your data. Use --rename-collection, --id-prefix, or --id-suffix.\n');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
62
|
+
console.log('\n' + '='.repeat(60));
|
|
63
|
+
console.log('š FSCOPY - INTERACTIVE MODE');
|
|
64
|
+
console.log('='.repeat(60) + '\n');
|
|
65
|
+
|
|
66
|
+
const sourceProject = await promptForProject(config.sourceProject, 'Source Firebase project ID', 'š¤');
|
|
67
|
+
const destProject = await promptForProject(config.destProject, 'Destination Firebase project ID', 'š„');
|
|
68
|
+
|
|
35
69
|
let idPrefix = config.idPrefix;
|
|
36
70
|
let idSuffix = config.idSuffix;
|
|
37
71
|
|
|
38
72
|
if (sourceProject === destProject) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const modifyIds = await confirm({
|
|
43
|
-
message: 'Add a prefix to document IDs?',
|
|
44
|
-
default: true,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
if (modifyIds) {
|
|
48
|
-
idPrefix = await input({
|
|
49
|
-
message: 'Document ID prefix (e.g., "backup_"):',
|
|
50
|
-
default: 'backup_',
|
|
51
|
-
validate: (value) => value.length > 0 || 'Prefix is required',
|
|
52
|
-
});
|
|
53
|
-
} else {
|
|
54
|
-
// Ask for suffix as alternative
|
|
55
|
-
const useSuffix = await confirm({
|
|
56
|
-
message: 'Add a suffix to document IDs instead?',
|
|
57
|
-
default: true,
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
if (useSuffix) {
|
|
61
|
-
idSuffix = await input({
|
|
62
|
-
message: 'Document ID suffix (e.g., "_backup"):',
|
|
63
|
-
default: '_backup',
|
|
64
|
-
validate: (value) => value.length > 0 || 'Suffix is required',
|
|
65
|
-
});
|
|
66
|
-
} else {
|
|
67
|
-
console.log('\nā Cannot proceed: source and destination are the same without ID modification.');
|
|
68
|
-
console.log(' This would overwrite your data. Use --rename-collection, --id-prefix, or --id-suffix.\n');
|
|
69
|
-
process.exit(1);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
73
|
+
const mods = await promptForIdModification(idPrefix, idSuffix);
|
|
74
|
+
idPrefix = mods.idPrefix;
|
|
75
|
+
idSuffix = mods.idSuffix;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
// Initialize source Firebase to list collections
|
|
@@ -165,7 +169,6 @@ export async function runInteractiveMode(config: Config): Promise<Config> {
|
|
|
165
169
|
includeSubcollections,
|
|
166
170
|
dryRun,
|
|
167
171
|
merge,
|
|
168
|
-
renameCollection,
|
|
169
172
|
idPrefix,
|
|
170
173
|
idSuffix,
|
|
171
174
|
};
|