@arela/uploader 1.0.3 → 1.0.5

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.
Files changed (44) hide show
  1. package/.env.local +316 -0
  2. package/coverage/IdentifyCommand.js.html +1462 -0
  3. package/coverage/PropagateCommand.js.html +1507 -0
  4. package/coverage/PushCommand.js.html +1504 -0
  5. package/coverage/ScanCommand.js.html +1654 -0
  6. package/coverage/UploadCommand.js.html +1846 -0
  7. package/coverage/WatchCommand.js.html +4111 -0
  8. package/coverage/base.css +224 -0
  9. package/coverage/block-navigation.js +87 -0
  10. package/coverage/favicon.png +0 -0
  11. package/coverage/index.html +191 -0
  12. package/coverage/lcov-report/IdentifyCommand.js.html +1462 -0
  13. package/coverage/lcov-report/PropagateCommand.js.html +1507 -0
  14. package/coverage/lcov-report/PushCommand.js.html +1504 -0
  15. package/coverage/lcov-report/ScanCommand.js.html +1654 -0
  16. package/coverage/lcov-report/UploadCommand.js.html +1846 -0
  17. package/coverage/lcov-report/WatchCommand.js.html +4111 -0
  18. package/coverage/lcov-report/base.css +224 -0
  19. package/coverage/lcov-report/block-navigation.js +87 -0
  20. package/coverage/lcov-report/favicon.png +0 -0
  21. package/coverage/lcov-report/index.html +191 -0
  22. package/coverage/lcov-report/prettify.css +1 -0
  23. package/coverage/lcov-report/prettify.js +2 -0
  24. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  25. package/coverage/lcov-report/sorter.js +210 -0
  26. package/coverage/lcov.info +1937 -0
  27. package/coverage/prettify.css +1 -0
  28. package/coverage/prettify.js +2 -0
  29. package/coverage/sort-arrow-sprite.png +0 -0
  30. package/coverage/sorter.js +210 -0
  31. package/docs/CROSS_PLATFORM_PATH_HANDLING.md +597 -0
  32. package/package.json +28 -2
  33. package/src/commands/IdentifyCommand.js +1 -28
  34. package/src/commands/PropagateCommand.js +1 -1
  35. package/src/commands/PushCommand.js +1 -1
  36. package/src/commands/ScanCommand.js +27 -20
  37. package/src/config/config.js +27 -48
  38. package/src/services/ScanApiService.js +4 -5
  39. package/src/utils/PathNormalizer.js +272 -0
  40. package/tests/commands/IdentifyCommand.test.js +570 -0
  41. package/tests/commands/PropagateCommand.test.js +568 -0
  42. package/tests/commands/PushCommand.test.js +754 -0
  43. package/tests/commands/ScanCommand.test.js +382 -0
  44. package/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
@@ -10,6 +10,7 @@ import appConfig from '../config/config.js';
10
10
  import ErrorHandler from '../errors/ErrorHandler.js';
11
11
  import { ConfigurationError } from '../errors/ErrorTypes.js';
12
12
  import FileOperations from '../utils/FileOperations.js';
13
+ import PathNormalizer from '../utils/PathNormalizer.js';
13
14
 
14
15
  /**
15
16
  * Scan Command Handler
@@ -40,13 +41,13 @@ export class ScanCommand {
40
41
  this.scanApiService = new ScanApiService();
41
42
 
42
43
  const scanConfig = appConfig.getScanConfig();
43
- const basePath = appConfig.getBasePath();
44
+ // Ensure basePath is absolute for scan operations
45
+ const basePath = PathNormalizer.toAbsolutePath(appConfig.getBasePath());
44
46
 
45
47
  logger.info('šŸ” Starting arela scan command');
46
48
  logger.info(`šŸ“¦ Company: ${scanConfig.companySlug}`);
47
49
  logger.info(`šŸ–„ļø Server: ${scanConfig.serverId}`);
48
50
  logger.info(`šŸ“‚ Base Path: ${basePath}`);
49
- logger.info(`šŸ·ļø Label: ${scanConfig.basePathLabel}`);
50
51
  logger.info(`šŸ“Š Directory Level: ${scanConfig.directoryLevel}`);
51
52
 
52
53
  // Step 1: Discover directories at specified level
@@ -63,15 +64,14 @@ export class ScanCommand {
63
64
  logger.info('\nšŸ“ Registering scan instances...');
64
65
  const registrations = [];
65
66
  for (const dir of directories) {
66
- const dirLabel = dir.label
67
- ? `${scanConfig.basePathLabel}_${dir.label.replace(/[^a-zA-Z0-9_-]/g, '_')}`
68
- : scanConfig.basePathLabel;
67
+ // dir.path is already absolute from #discoverDirectories
68
+ // Use the absolute path as basePathLabel (simplifies everything!)
69
+ const absolutePath = dir.path;
69
70
 
70
71
  const registration = await this.scanApiService.registerInstance({
71
72
  companySlug: scanConfig.companySlug,
72
73
  serverId: scanConfig.serverId,
73
- basePathLabel: dirLabel,
74
- basePathFull: dir.path,
74
+ basePathFull: absolutePath,
75
75
  });
76
76
 
77
77
  registrations.push({ ...registration, directory: dir });
@@ -181,8 +181,8 @@ export class ScanCommand {
181
181
  return sources.map((source) => {
182
182
  const sourcePath =
183
183
  source === '.' ? basePath : path.resolve(basePath, source);
184
- const label =
185
- source === '.' ? '' : source.replace(/[^a-zA-Z0-9_-]/g, '_');
184
+ // Label is relative path for display purposes only
185
+ const label = source === '.' ? '' : source;
186
186
  return { path: sourcePath, label };
187
187
  });
188
188
  }
@@ -204,20 +204,20 @@ export class ScanCommand {
204
204
  // Source is current directory, use discovered path as-is
205
205
  directories.push(levelDir);
206
206
  } else {
207
- // Append source to both path and label
207
+ // Append source to path
208
208
  const combinedPath = path.resolve(levelDir.path, source);
209
- const sourceLabel = source.replace(/[^a-zA-Z0-9_-]/g, '_');
210
- const combinedLabel = levelDir.label
211
- ? `${levelDir.label}_${sourceLabel}`
212
- : sourceLabel;
213
209
 
214
210
  // Only add if the combined path actually exists
215
211
  try {
216
212
  const stats = await fs.stat(combinedPath);
217
213
  if (stats.isDirectory()) {
214
+ // Label for display
215
+ const label = levelDir.label
216
+ ? `${levelDir.label}/${source}`
217
+ : source;
218
218
  directories.push({
219
219
  path: combinedPath,
220
- label: combinedLabel,
220
+ label,
221
221
  });
222
222
  } else {
223
223
  logger.debug(`ā­ļø Skipping ${combinedPath} (not a directory)`);
@@ -249,8 +249,9 @@ export class ScanCommand {
249
249
  const fullPath = path.join(basePath, currentPath);
250
250
 
251
251
  if (currentLevel === targetLevel) {
252
- const label = currentPath.replace(/\\/g, '/').replace(/^\//g, '');
253
- return [{ path: fullPath, label: label || 'root' }];
252
+ // Label is the relative path for display
253
+ const label = currentPath || '';
254
+ return [{ path: fullPath, label }];
254
255
  }
255
256
 
256
257
  const directories = [];
@@ -418,20 +419,26 @@ export class ScanCommand {
418
419
 
419
420
  /**
420
421
  * Normalize file record for database insertion
422
+ * Stores paths with forward slashes for consistency but keeps them absolute
421
423
  * @private
422
424
  */
423
425
  #normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
424
426
  const fileName = path.basename(filePath);
425
427
  const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
426
- const directoryPath = path.dirname(filePath);
427
- const relativePath = path.relative(basePath, filePath);
428
+
429
+ // Normalize separators to forward slashes for consistency
430
+ const directoryPath = PathNormalizer.normalizeSeparators(
431
+ path.dirname(filePath),
432
+ );
433
+ const relativePath = PathNormalizer.getRelativePath(filePath, basePath);
434
+ const absolutePath = PathNormalizer.normalizeSeparators(filePath);
428
435
 
429
436
  return {
430
437
  fileName,
431
438
  fileExtension,
432
439
  directoryPath,
433
440
  relativePath,
434
- absolutePath: filePath,
441
+ absolutePath,
435
442
  sizeBytes: Number(fileStats.size),
436
443
  modifiedAt: fileStats.mtime.toISOString(),
437
444
  scanTimestamp,
@@ -3,6 +3,8 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
 
6
+ import PathNormalizer from '../utils/PathNormalizer.js';
7
+
6
8
  config();
7
9
 
8
10
  /**
@@ -32,10 +34,10 @@ class Config {
32
34
  const __dirname = path.dirname(__filename);
33
35
  const packageJsonPath = path.resolve(__dirname, '../../package.json');
34
36
  const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
35
- return packageJson.version || '1.0.3';
37
+ return packageJson.version || '1.0.5';
36
38
  } catch (error) {
37
39
  console.warn('āš ļø Could not read package.json version, using fallback');
38
- return '1.0.3';
40
+ return '1.0.5';
39
41
  }
40
42
  }
41
43
 
@@ -251,13 +253,18 @@ class Config {
251
253
  let basePathLabel = process.env.ARELA_BASE_PATH_LABEL;
252
254
 
253
255
  // Auto-derive basePathLabel from UPLOAD_BASE_PATH if not set
256
+ // IMPORTANT: Always resolve to absolute path for uniqueness
254
257
  if (!basePathLabel && process.env.UPLOAD_BASE_PATH) {
255
258
  const basePath = process.env.UPLOAD_BASE_PATH;
256
- // Get the last segment of the path
257
- const segments = basePath.split(path.sep).filter(Boolean);
258
- basePathLabel = segments[segments.length - 1] || 'root';
259
- // Sanitize the label
260
- basePathLabel = basePathLabel.replace(/[^a-zA-Z0-9_-]/g, '_');
259
+ // Resolve to absolute path (handles ../sample vs ./sample correctly)
260
+ // Note: toAbsolutePath handles Windows paths (O:\...) even on macOS/Linux
261
+ basePathLabel = PathNormalizer.toAbsolutePath(basePath);
262
+ }
263
+
264
+ // If basePathLabel is provided, ensure it's absolute
265
+ // Use PathNormalizer.isAbsolutePath for cross-platform Windows path detection
266
+ if (basePathLabel && !PathNormalizer.isAbsolutePath(basePathLabel)) {
267
+ basePathLabel = PathNormalizer.toAbsolutePath(basePathLabel);
261
268
  }
262
269
 
263
270
  // Parse exclude patterns
@@ -271,16 +278,21 @@ class Config {
271
278
  .filter(Boolean);
272
279
 
273
280
  // Generate table name if all components are available
281
+ // Note: This is just for reference; actual table names are generated dynamically
282
+ // in ScanCommand based on discovered directories and levels
274
283
  let tableName = null;
275
284
  if (companySlug && serverId && basePathLabel) {
276
- const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
277
- tableName = this.#sanitizeTableName(rawName);
285
+ tableName = PathNormalizer.generateTableName({
286
+ companySlug,
287
+ serverId,
288
+ basePathLabel,
289
+ });
278
290
  }
279
291
 
280
292
  return {
281
293
  companySlug,
282
294
  serverId,
283
- basePathLabel,
295
+ basePathFull: basePathLabel, // Renamed for consistency
284
296
  tableName,
285
297
  excludePatterns,
286
298
  batchSize: parseInt(process.env.SCAN_BATCH_SIZE) || 2000,
@@ -311,38 +323,6 @@ class Config {
311
323
  };
312
324
  }
313
325
 
314
- /**
315
- * Sanitize and generate table name
316
- * @private
317
- */
318
- #sanitizeTableName(rawName) {
319
- // Sanitize: lowercase, replace special chars with underscore
320
- let sanitized = rawName
321
- .toLowerCase()
322
- .replace(/[^a-z0-9_]/g, '_')
323
- .replace(/_+/g, '_')
324
- .replace(/^_|_$/g, '');
325
-
326
- const prefix = 'scan_';
327
- let tableName = prefix + sanitized;
328
-
329
- // PostgreSQL table name limit is 63 characters
330
- if (tableName.length > 63) {
331
- // Simple hash without crypto module
332
- let hash = 0;
333
- for (let i = 0; i < rawName.length; i++) {
334
- const char = rawName.charCodeAt(i);
335
- hash = (hash << 5) - hash + char;
336
- hash = hash & hash; // Convert to 32bit integer
337
- }
338
- const hashStr = Math.abs(hash).toString(36).substring(0, 8);
339
- const maxBaseLength = 63 - hashStr.length - 1;
340
- tableName = tableName.substring(0, maxBaseLength) + '_' + hashStr;
341
- }
342
-
343
- return tableName;
344
- }
345
-
346
326
  /**
347
327
  * Validate scan configuration
348
328
  * @throws {Error} If required scan configuration is missing
@@ -376,9 +356,9 @@ class Config {
376
356
  );
377
357
  }
378
358
 
379
- if (!this.scan.basePathLabel) {
359
+ if (!this.scan.basePathFull) {
380
360
  errors.push(
381
- 'Could not determine base path label. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
361
+ 'Could not determine base path. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
382
362
  );
383
363
  }
384
364
 
@@ -394,10 +374,8 @@ class Config {
394
374
  * @returns {Object} Scan configuration
395
375
  */
396
376
  getScanConfig() {
397
- return {
398
- ...this.scan,
399
- basePathFull: this.upload.basePath,
400
- };
377
+ // Return scan config with basePathFull already resolved to absolute path
378
+ return this.scan;
401
379
  }
402
380
 
403
381
  /**
@@ -549,6 +527,7 @@ class Config {
549
527
 
550
528
  /**
551
529
  * Get base path with validation
530
+ * Returns the path as configured (may be relative for legacy compatibility)
552
531
  * @returns {string} Base path for uploads
553
532
  * @throws {Error} If base path is not configured
554
533
  */
@@ -249,7 +249,6 @@ export class ScanApiService {
249
249
  const result = await this.#request('/api/uploader/scan/register', 'POST', {
250
250
  companySlug: config.companySlug,
251
251
  serverId: config.serverId,
252
- basePathLabel: config.basePathLabel,
253
252
  basePathFull: config.basePathFull,
254
253
  });
255
254
 
@@ -327,15 +326,15 @@ export class ScanApiService {
327
326
  * Get all tables for a specific instance
328
327
  * @param {string} companySlug - Company slug
329
328
  * @param {string} serverId - Server ID
330
- * @param {string} basePathLabel - Base path label
329
+ * @param {string} basePathFull - Base path (absolute)
331
330
  * @returns {Promise<Array>} List of tables for the instance
332
331
  */
333
- async getInstanceTables(companySlug, serverId, basePathLabel) {
332
+ async getInstanceTables(companySlug, serverId, basePathFull) {
334
333
  logger.debug(
335
- `Fetching instance tables for ${companySlug}/${serverId}/${basePathLabel}...`,
334
+ `Fetching instance tables for ${companySlug}/${serverId}/${basePathFull}...`,
336
335
  );
337
336
  return await this.#request(
338
- `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathLabel=${encodeURIComponent(basePathLabel)}`,
337
+ `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
339
338
  'GET',
340
339
  );
341
340
  }
@@ -0,0 +1,272 @@
1
+ import crypto from 'crypto';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ /**
6
+ * PathNormalizer - Absolute path resolution and table name generation
7
+ *
8
+ * Strategy:
9
+ * 1. Always resolve paths to absolute paths first (resolves ../sample vs ./sample)
10
+ * 2. Store absolute paths in base_path_full
11
+ * 3. Normalize ONLY for table name generation (sanitize special chars)
12
+ * 4. Keep full path structure in table names (hash if too long)
13
+ *
14
+ * Cross-platform support:
15
+ * - Recognizes Windows paths (C:\, D:\, etc.) even when running on macOS/Linux
16
+ * - Preserves Windows paths as-is when detected (for remote server scenarios)
17
+ * - This allows CLI running on macOS to work with paths stored from Windows servers
18
+ */
19
+ export class PathNormalizer {
20
+ /**
21
+ * Check if a path is a Windows-style absolute path (has drive letter)
22
+ * This works on any OS, not just Windows
23
+ *
24
+ * Examples:
25
+ * - C:\Users\Documents -> true
26
+ * - O:\expediente\archivos -> true
27
+ * - /home/user/docs -> false
28
+ * - ./relative/path -> false
29
+ *
30
+ * @param {string} inputPath - Path to check
31
+ * @returns {boolean} True if path has a Windows drive letter
32
+ */
33
+ static isWindowsAbsolutePath(inputPath) {
34
+ if (!inputPath || typeof inputPath !== 'string') return false;
35
+ // Match drive letter followed by colon and backslash or forward slash
36
+ // e.g., C:\, D:/, O:\
37
+ return /^[A-Za-z]:[/\\]/.test(inputPath);
38
+ }
39
+
40
+ /**
41
+ * Check if a path is absolute (works cross-platform)
42
+ * Recognizes both Unix and Windows absolute paths regardless of current OS
43
+ *
44
+ * @param {string} inputPath - Path to check
45
+ * @returns {boolean} True if path is absolute (Unix or Windows style)
46
+ */
47
+ static isAbsolutePath(inputPath) {
48
+ if (!inputPath) return false;
49
+ // Check native absolute path first, then Windows-style
50
+ return path.isAbsolute(inputPath) || this.isWindowsAbsolutePath(inputPath);
51
+ }
52
+
53
+ /**
54
+ * Resolve a path to an absolute path
55
+ * This ensures ../sample and ./sample are treated as different paths
56
+ *
57
+ * Cross-platform behavior:
58
+ * - Windows paths (C:\, O:\, etc.) are preserved as-is, even on macOS/Linux
59
+ * - Unix paths are normalized using native path.resolve
60
+ * - Relative paths are resolved against basePath or cwd
61
+ *
62
+ * Examples:
63
+ * - ../sample (from /data/project) -> /data/sample
64
+ * - ./sample (from /data/project) -> /data/project/sample
65
+ * - C:\Users\Documents -> C:\Users\Documents (preserved on any OS)
66
+ * - O:\expediente\archivos -> O:\expediente\archivos (preserved on any OS)
67
+ * - /home/user/docs -> /home/user/docs (on Unix)
68
+ *
69
+ * @param {string} inputPath - Path to resolve
70
+ * @param {string} basePath - Base path for resolving relative paths (defaults to cwd)
71
+ * @returns {string} Absolute path (native format for Unix, preserved for Windows)
72
+ */
73
+ static toAbsolutePath(inputPath, basePath = null) {
74
+ if (!inputPath) return '';
75
+
76
+ // Check for Windows absolute path first (works on any OS)
77
+ // This prevents macOS from treating "O:\path" as relative
78
+ if (this.isWindowsAbsolutePath(inputPath)) {
79
+ // Normalize Windows path but preserve the format
80
+ // Replace forward slashes with backslashes for consistency
81
+ return inputPath.replace(/\//g, '\\');
82
+ }
83
+
84
+ // If native absolute path, return normalized
85
+ if (path.isAbsolute(inputPath)) {
86
+ return path.normalize(inputPath);
87
+ }
88
+
89
+ // Resolve relative path against base
90
+ const base = basePath || process.cwd();
91
+ return path.resolve(base, inputPath);
92
+ }
93
+
94
+ /**
95
+ * Normalize path separators to forward slashes for consistency
96
+ * Does NOT remove drive letters or convert to POSIX - just normalizes separators
97
+ *
98
+ * @param {string} inputPath - Path to normalize
99
+ * @returns {string} Path with forward slashes
100
+ */
101
+ static normalizeSeparators(inputPath) {
102
+ if (!inputPath) return '';
103
+ return inputPath.replace(/\\/g, '/');
104
+ }
105
+
106
+ /**
107
+ * Get relative path from base with normalized separators
108
+ *
109
+ * @param {string} fullPath - Full absolute path
110
+ * @param {string} basePath - Base absolute path to subtract
111
+ * @returns {string} Relative path with forward slashes
112
+ */
113
+ static getRelativePath(fullPath, basePath) {
114
+ const relativePath = path.relative(basePath, fullPath);
115
+ return this.normalizeSeparators(relativePath);
116
+ }
117
+
118
+ /**
119
+ * Sanitize an absolute path for use in database table names
120
+ * Converts path separators and special characters to underscores
121
+ * Preserves the path structure for uniqueness
122
+ *
123
+ * Examples:
124
+ * - /data/2023 -> data_2023
125
+ * - C:\Users\Documents -> c_users_documents
126
+ * - /home/user/project/sample -> home_user_project_sample
127
+ *
128
+ * @param {string} absolutePath - Absolute path to sanitize
129
+ * @returns {string} Sanitized string safe for table names
130
+ */
131
+ static sanitizeForTableName(absolutePath) {
132
+ if (!absolutePath) return '';
133
+
134
+ return absolutePath
135
+ .toLowerCase()
136
+ .replace(/^[a-z]:/i, '') // Remove Windows drive letter (C:, D:, etc.)
137
+ .replace(/[^a-z0-9]/g, '_') // Replace all non-alphanumeric with underscore
138
+ .replace(/_+/g, '_') // Replace multiple underscores with single
139
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
140
+ }
141
+
142
+ /**
143
+ * Generate a PostgreSQL-compatible table name from scan configuration
144
+ * Format: scan_<company>_<server>_<sanitized_absolute_path>
145
+ *
146
+ * Uses the absolute path to ensure uniqueness:
147
+ * - ../sample -> /data/sample
148
+ * - ./sample -> /data/project/sample
149
+ * These create different table names!
150
+ *
151
+ * Handles the 63-character PostgreSQL limit by:
152
+ * 1. Trying the full name first
153
+ * 2. If too long, truncating and adding an 8-character hash for uniqueness
154
+ *
155
+ * @param {Object} config - Scan configuration
156
+ * @param {string} config.companySlug - Company identifier
157
+ * @param {string} config.serverId - Server identifier
158
+ * @param {string} config.basePathLabel - Absolute base path
159
+ * @returns {string} PostgreSQL-compatible table name
160
+ */
161
+ static generateTableName(config) {
162
+ const { companySlug, serverId, basePathLabel } = config;
163
+
164
+ // Sanitize each component (basePathLabel should already be absolute)
165
+ const sanitizedCompany = companySlug
166
+ .toLowerCase()
167
+ .replace(/[^a-z0-9]/g, '_');
168
+ const sanitizedServer = serverId.toLowerCase().replace(/[^a-z0-9]/g, '_');
169
+ const sanitizedPath = this.sanitizeForTableName(basePathLabel);
170
+
171
+ // Build raw name for hashing (preserve original for uniqueness)
172
+ const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
173
+
174
+ // Build sanitized table name
175
+ let tableName = `scan_${sanitizedCompany}_${sanitizedServer}`;
176
+
177
+ if (sanitizedPath) {
178
+ tableName += `_${sanitizedPath}`;
179
+ }
180
+
181
+ // PostgreSQL table name limit is 63 characters
182
+ if (tableName.length > 63) {
183
+ // Generate a hash for uniqueness
184
+ const hash = crypto
185
+ .createHash('md5')
186
+ .update(rawName)
187
+ .digest('hex')
188
+ .substring(0, 8);
189
+
190
+ const basePrefix = 'scan_';
191
+ const prefix = `${basePrefix}${sanitizedCompany}_${sanitizedServer}_`;
192
+
193
+ // Check if even prefix + hash exceeds limit
194
+ if (prefix.length + hash.length > 63) {
195
+ // Company/server names are too long, need to truncate them too
196
+ const maxCompanyServerLength = 63 - basePrefix.length - hash.length - 2; // -2 for underscores
197
+ const halfLength = Math.floor(maxCompanyServerLength / 2);
198
+ const companyLength = halfLength;
199
+ const serverLength = maxCompanyServerLength - companyLength;
200
+
201
+ const truncatedCompany = sanitizedCompany.substring(0, companyLength);
202
+ const truncatedServer = sanitizedServer.substring(0, serverLength);
203
+
204
+ tableName = `${basePrefix}${truncatedCompany}_${truncatedServer}_${hash}`;
205
+ } else {
206
+ // Preserve start and end of path, put hash in middle
207
+ const availableSpace = 63 - prefix.length - hash.length - 2; // -2 for underscores around hash
208
+
209
+ if (availableSpace <= 0 || !sanitizedPath) {
210
+ // If no space for path or path is empty, just use hash
211
+ tableName = `${prefix}${hash}`;
212
+ } else if (sanitizedPath.length <= availableSpace) {
213
+ // Path fits in available space, use it all
214
+ tableName = `${prefix}${sanitizedPath}_${hash}`;
215
+ } else {
216
+ // Need to split: preserve start and end
217
+ const halfSpace = Math.floor(availableSpace / 2);
218
+ const startLength = halfSpace;
219
+ const endLength = availableSpace - startLength;
220
+
221
+ // Extract start and end portions of the sanitized path
222
+ const pathStart = sanitizedPath.substring(0, startLength);
223
+ const pathEnd = sanitizedPath.substring(sanitizedPath.length - endLength);
224
+
225
+ // Build table name with start, hash, and end
226
+ tableName = `${prefix}${pathStart}_${hash}_${pathEnd}`;
227
+ }
228
+ }
229
+ }
230
+
231
+ return tableName;
232
+ }
233
+
234
+ /**
235
+ * Build an absolute path from base and relative component
236
+ *
237
+ * @param {string} basePath - Absolute base path
238
+ * @param {string} relativePath - Relative path from base (can be empty)
239
+ * @returns {string} Absolute path
240
+ */
241
+ static buildAbsolutePath(basePath, relativePath = '') {
242
+ if (!relativePath || relativePath === '.') {
243
+ return basePath;
244
+ }
245
+ return path.resolve(basePath, relativePath);
246
+ }
247
+
248
+ /**
249
+ * Validate that a path can be used for table name generation
250
+ *
251
+ * @param {string} pathStr - Path to validate
252
+ * @returns {Object} Validation result { valid: boolean, error?: string }
253
+ */
254
+ static validatePath(pathStr) {
255
+ if (!pathStr || typeof pathStr !== 'string') {
256
+ return { valid: false, error: 'Path is required and must be a string' };
257
+ }
258
+
259
+ if (pathStr.trim() === '') {
260
+ return { valid: false, error: 'Path cannot be empty' };
261
+ }
262
+
263
+ // Check for null bytes (security)
264
+ if (pathStr.includes('\0')) {
265
+ return { valid: false, error: 'Path cannot contain null bytes' };
266
+ }
267
+
268
+ return { valid: true };
269
+ }
270
+ }
271
+
272
+ export default PathNormalizer;