@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.
@@ -26,6 +26,10 @@ export const defaults: Config = {
26
26
  rateLimit: 0,
27
27
  skipOversized: false,
28
28
  json: false,
29
+ transformSamples: 3,
30
+ detectConflicts: false,
31
+ maxDepth: 0,
32
+ verifyIntegrity: false,
29
33
  };
30
34
 
31
35
  export const iniTemplate = `; fscopy configuration file
@@ -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
  }
@@ -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
+ }
@@ -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
- export async function runInteractiveMode(config: Config): Promise<Config> {
7
- console.log('\n' + '='.repeat(60));
8
- console.log('šŸ”„ FSCOPY - INTERACTIVE MODE');
9
- console.log('='.repeat(60) + '\n');
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
- // Prompt for source project if not set
12
- let sourceProject = config.sourceProject;
13
- if (!sourceProject) {
14
- sourceProject = await input({
15
- message: 'Source Firebase project ID:',
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
- } else {
19
- console.log(`šŸ“¤ Source project: ${sourceProject}`);
39
+ return { idPrefix, idSuffix: currentSuffix };
20
40
  }
21
41
 
22
- // Prompt for destination project if not set
23
- let destProject = config.destProject;
24
- if (!destProject) {
25
- destProject = await input({
26
- message: 'Destination Firebase project ID:',
27
- validate: (value) => value.length > 0 || 'Project ID is required',
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
- } else {
30
- console.log(`šŸ“„ Destination project: ${destProject}`);
53
+ return { idPrefix: currentPrefix, idSuffix };
31
54
  }
32
55
 
33
- // If source = destination, ask for rename/id modifications
34
- const renameCollection = config.renameCollection;
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
- console.log('\nāš ļø Source and destination are the same project.');
40
- console.log(' You need to rename collections or modify document IDs to avoid overwriting.\n');
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
  };