@arela/uploader 1.0.3 ā 1.0.4
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/.env.local +316 -0
- package/coverage/IdentifyCommand.js.html +1462 -0
- package/coverage/PropagateCommand.js.html +1507 -0
- package/coverage/PushCommand.js.html +1504 -0
- package/coverage/ScanCommand.js.html +1654 -0
- package/coverage/UploadCommand.js.html +1846 -0
- package/coverage/WatchCommand.js.html +4111 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +191 -0
- package/coverage/lcov-report/IdentifyCommand.js.html +1462 -0
- package/coverage/lcov-report/PropagateCommand.js.html +1507 -0
- package/coverage/lcov-report/PushCommand.js.html +1504 -0
- package/coverage/lcov-report/ScanCommand.js.html +1654 -0
- package/coverage/lcov-report/UploadCommand.js.html +1846 -0
- package/coverage/lcov-report/WatchCommand.js.html +4111 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +191 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +1937 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/docs/CROSS_PLATFORM_PATH_HANDLING.md +593 -0
- package/package.json +28 -2
- package/src/commands/IdentifyCommand.js +1 -28
- package/src/commands/PropagateCommand.js +1 -1
- package/src/commands/PushCommand.js +1 -1
- package/src/commands/ScanCommand.js +27 -20
- package/src/config/config.js +25 -48
- package/src/services/ScanApiService.js +4 -5
- package/src/utils/PathNormalizer.js +220 -0
- package/tests/commands/IdentifyCommand.test.js +570 -0
- package/tests/commands/PropagateCommand.test.js +568 -0
- package/tests/commands/PushCommand.test.js +754 -0
- package/tests/commands/ScanCommand.test.js +382 -0
- 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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
427
|
-
|
|
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
|
|
441
|
+
absolutePath,
|
|
435
442
|
sizeBytes: Number(fileStats.size),
|
|
436
443
|
modifiedAt: fileStats.mtime.toISOString(),
|
|
437
444
|
scanTimestamp,
|
package/src/config/config.js
CHANGED
|
@@ -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.
|
|
37
|
+
return packageJson.version || '1.0.4';
|
|
36
38
|
} catch (error) {
|
|
37
39
|
console.warn('ā ļø Could not read package.json version, using fallback');
|
|
38
|
-
return '1.0.
|
|
40
|
+
return '1.0.4';
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -251,13 +253,16 @@ 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
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
// Resolve to absolute path (handles ../sample vs ./sample correctly)
|
|
260
|
+
basePathLabel = PathNormalizer.toAbsolutePath(basePath);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// If basePathLabel is provided, ensure it's absolute
|
|
264
|
+
if (basePathLabel && !path.isAbsolute(basePathLabel)) {
|
|
265
|
+
basePathLabel = PathNormalizer.toAbsolutePath(basePathLabel);
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
// Parse exclude patterns
|
|
@@ -271,16 +276,21 @@ class Config {
|
|
|
271
276
|
.filter(Boolean);
|
|
272
277
|
|
|
273
278
|
// Generate table name if all components are available
|
|
279
|
+
// Note: This is just for reference; actual table names are generated dynamically
|
|
280
|
+
// in ScanCommand based on discovered directories and levels
|
|
274
281
|
let tableName = null;
|
|
275
282
|
if (companySlug && serverId && basePathLabel) {
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
tableName = PathNormalizer.generateTableName({
|
|
284
|
+
companySlug,
|
|
285
|
+
serverId,
|
|
286
|
+
basePathLabel,
|
|
287
|
+
});
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
return {
|
|
281
291
|
companySlug,
|
|
282
292
|
serverId,
|
|
283
|
-
basePathLabel,
|
|
293
|
+
basePathFull: basePathLabel, // Renamed for consistency
|
|
284
294
|
tableName,
|
|
285
295
|
excludePatterns,
|
|
286
296
|
batchSize: parseInt(process.env.SCAN_BATCH_SIZE) || 2000,
|
|
@@ -311,38 +321,6 @@ class Config {
|
|
|
311
321
|
};
|
|
312
322
|
}
|
|
313
323
|
|
|
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
324
|
/**
|
|
347
325
|
* Validate scan configuration
|
|
348
326
|
* @throws {Error} If required scan configuration is missing
|
|
@@ -376,9 +354,9 @@ class Config {
|
|
|
376
354
|
);
|
|
377
355
|
}
|
|
378
356
|
|
|
379
|
-
if (!this.scan.
|
|
357
|
+
if (!this.scan.basePathFull) {
|
|
380
358
|
errors.push(
|
|
381
|
-
'Could not determine base path
|
|
359
|
+
'Could not determine base path. Set ARELA_BASE_PATH_LABEL or UPLOAD_BASE_PATH',
|
|
382
360
|
);
|
|
383
361
|
}
|
|
384
362
|
|
|
@@ -394,10 +372,8 @@ class Config {
|
|
|
394
372
|
* @returns {Object} Scan configuration
|
|
395
373
|
*/
|
|
396
374
|
getScanConfig() {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
basePathFull: this.upload.basePath,
|
|
400
|
-
};
|
|
375
|
+
// Return scan config with basePathFull already resolved to absolute path
|
|
376
|
+
return this.scan;
|
|
401
377
|
}
|
|
402
378
|
|
|
403
379
|
/**
|
|
@@ -549,6 +525,7 @@ class Config {
|
|
|
549
525
|
|
|
550
526
|
/**
|
|
551
527
|
* Get base path with validation
|
|
528
|
+
* Returns the path as configured (may be relative for legacy compatibility)
|
|
552
529
|
* @returns {string} Base path for uploads
|
|
553
530
|
* @throws {Error} If base path is not configured
|
|
554
531
|
*/
|
|
@@ -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}
|
|
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,
|
|
332
|
+
async getInstanceTables(companySlug, serverId, basePathFull) {
|
|
334
333
|
logger.debug(
|
|
335
|
-
`Fetching instance tables for ${companySlug}/${serverId}/${
|
|
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)}&
|
|
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,220 @@
|
|
|
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
|
+
export class PathNormalizer {
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a path to an absolute path
|
|
17
|
+
* This ensures ../sample and ./sample are treated as different paths
|
|
18
|
+
*
|
|
19
|
+
* Examples:
|
|
20
|
+
* - ../sample (from /data/project) -> /data/sample
|
|
21
|
+
* - ./sample (from /data/project) -> /data/project/sample
|
|
22
|
+
* - C:\Users\Documents -> C:\Users\Documents (on Windows)
|
|
23
|
+
* - /home/user/docs -> /home/user/docs (on Unix)
|
|
24
|
+
*
|
|
25
|
+
* @param {string} inputPath - Path to resolve
|
|
26
|
+
* @param {string} basePath - Base path for resolving relative paths (defaults to cwd)
|
|
27
|
+
* @returns {string} Absolute path in native OS format
|
|
28
|
+
*/
|
|
29
|
+
static toAbsolutePath(inputPath, basePath = null) {
|
|
30
|
+
if (!inputPath) return '';
|
|
31
|
+
|
|
32
|
+
// If already absolute, return as-is
|
|
33
|
+
if (path.isAbsolute(inputPath)) {
|
|
34
|
+
return path.normalize(inputPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Resolve relative path
|
|
38
|
+
const base = basePath || process.cwd();
|
|
39
|
+
return path.resolve(base, inputPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize path separators to forward slashes for consistency
|
|
44
|
+
* Does NOT remove drive letters or convert to POSIX - just normalizes separators
|
|
45
|
+
*
|
|
46
|
+
* @param {string} inputPath - Path to normalize
|
|
47
|
+
* @returns {string} Path with forward slashes
|
|
48
|
+
*/
|
|
49
|
+
static normalizeSeparators(inputPath) {
|
|
50
|
+
if (!inputPath) return '';
|
|
51
|
+
return inputPath.replace(/\\/g, '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get relative path from base with normalized separators
|
|
56
|
+
*
|
|
57
|
+
* @param {string} fullPath - Full absolute path
|
|
58
|
+
* @param {string} basePath - Base absolute path to subtract
|
|
59
|
+
* @returns {string} Relative path with forward slashes
|
|
60
|
+
*/
|
|
61
|
+
static getRelativePath(fullPath, basePath) {
|
|
62
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
63
|
+
return this.normalizeSeparators(relativePath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sanitize an absolute path for use in database table names
|
|
68
|
+
* Converts path separators and special characters to underscores
|
|
69
|
+
* Preserves the path structure for uniqueness
|
|
70
|
+
*
|
|
71
|
+
* Examples:
|
|
72
|
+
* - /data/2023 -> data_2023
|
|
73
|
+
* - C:\Users\Documents -> c_users_documents
|
|
74
|
+
* - /home/user/project/sample -> home_user_project_sample
|
|
75
|
+
*
|
|
76
|
+
* @param {string} absolutePath - Absolute path to sanitize
|
|
77
|
+
* @returns {string} Sanitized string safe for table names
|
|
78
|
+
*/
|
|
79
|
+
static sanitizeForTableName(absolutePath) {
|
|
80
|
+
if (!absolutePath) return '';
|
|
81
|
+
|
|
82
|
+
return absolutePath
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/^[a-z]:/i, '') // Remove Windows drive letter (C:, D:, etc.)
|
|
85
|
+
.replace(/[^a-z0-9]/g, '_') // Replace all non-alphanumeric with underscore
|
|
86
|
+
.replace(/_+/g, '_') // Replace multiple underscores with single
|
|
87
|
+
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate a PostgreSQL-compatible table name from scan configuration
|
|
92
|
+
* Format: scan_<company>_<server>_<sanitized_absolute_path>
|
|
93
|
+
*
|
|
94
|
+
* Uses the absolute path to ensure uniqueness:
|
|
95
|
+
* - ../sample -> /data/sample
|
|
96
|
+
* - ./sample -> /data/project/sample
|
|
97
|
+
* These create different table names!
|
|
98
|
+
*
|
|
99
|
+
* Handles the 63-character PostgreSQL limit by:
|
|
100
|
+
* 1. Trying the full name first
|
|
101
|
+
* 2. If too long, truncating and adding an 8-character hash for uniqueness
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} config - Scan configuration
|
|
104
|
+
* @param {string} config.companySlug - Company identifier
|
|
105
|
+
* @param {string} config.serverId - Server identifier
|
|
106
|
+
* @param {string} config.basePathLabel - Absolute base path
|
|
107
|
+
* @returns {string} PostgreSQL-compatible table name
|
|
108
|
+
*/
|
|
109
|
+
static generateTableName(config) {
|
|
110
|
+
const { companySlug, serverId, basePathLabel } = config;
|
|
111
|
+
|
|
112
|
+
// Sanitize each component (basePathLabel should already be absolute)
|
|
113
|
+
const sanitizedCompany = companySlug
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.replace(/[^a-z0-9]/g, '_');
|
|
116
|
+
const sanitizedServer = serverId.toLowerCase().replace(/[^a-z0-9]/g, '_');
|
|
117
|
+
const sanitizedPath = this.sanitizeForTableName(basePathLabel);
|
|
118
|
+
|
|
119
|
+
// Build raw name for hashing (preserve original for uniqueness)
|
|
120
|
+
const rawName = `${companySlug}_${serverId}_${basePathLabel}`;
|
|
121
|
+
|
|
122
|
+
// Build sanitized table name
|
|
123
|
+
let tableName = `scan_${sanitizedCompany}_${sanitizedServer}`;
|
|
124
|
+
|
|
125
|
+
if (sanitizedPath) {
|
|
126
|
+
tableName += `_${sanitizedPath}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// PostgreSQL table name limit is 63 characters
|
|
130
|
+
if (tableName.length > 63) {
|
|
131
|
+
// Generate a hash for uniqueness
|
|
132
|
+
const hash = crypto
|
|
133
|
+
.createHash('md5')
|
|
134
|
+
.update(rawName)
|
|
135
|
+
.digest('hex')
|
|
136
|
+
.substring(0, 8);
|
|
137
|
+
|
|
138
|
+
const basePrefix = 'scan_';
|
|
139
|
+
const prefix = `${basePrefix}${sanitizedCompany}_${sanitizedServer}_`;
|
|
140
|
+
|
|
141
|
+
// Check if even prefix + hash exceeds limit
|
|
142
|
+
if (prefix.length + hash.length > 63) {
|
|
143
|
+
// Company/server names are too long, need to truncate them too
|
|
144
|
+
const maxCompanyServerLength = 63 - basePrefix.length - hash.length - 2; // -2 for underscores
|
|
145
|
+
const halfLength = Math.floor(maxCompanyServerLength / 2);
|
|
146
|
+
const companyLength = halfLength;
|
|
147
|
+
const serverLength = maxCompanyServerLength - companyLength;
|
|
148
|
+
|
|
149
|
+
const truncatedCompany = sanitizedCompany.substring(0, companyLength);
|
|
150
|
+
const truncatedServer = sanitizedServer.substring(0, serverLength);
|
|
151
|
+
|
|
152
|
+
tableName = `${basePrefix}${truncatedCompany}_${truncatedServer}_${hash}`;
|
|
153
|
+
} else {
|
|
154
|
+
// Preserve start and end of path, put hash in middle
|
|
155
|
+
const availableSpace = 63 - prefix.length - hash.length - 2; // -2 for underscores around hash
|
|
156
|
+
|
|
157
|
+
if (availableSpace <= 0 || !sanitizedPath) {
|
|
158
|
+
// If no space for path or path is empty, just use hash
|
|
159
|
+
tableName = `${prefix}${hash}`;
|
|
160
|
+
} else if (sanitizedPath.length <= availableSpace) {
|
|
161
|
+
// Path fits in available space, use it all
|
|
162
|
+
tableName = `${prefix}${sanitizedPath}_${hash}`;
|
|
163
|
+
} else {
|
|
164
|
+
// Need to split: preserve start and end
|
|
165
|
+
const halfSpace = Math.floor(availableSpace / 2);
|
|
166
|
+
const startLength = halfSpace;
|
|
167
|
+
const endLength = availableSpace - startLength;
|
|
168
|
+
|
|
169
|
+
// Extract start and end portions of the sanitized path
|
|
170
|
+
const pathStart = sanitizedPath.substring(0, startLength);
|
|
171
|
+
const pathEnd = sanitizedPath.substring(sanitizedPath.length - endLength);
|
|
172
|
+
|
|
173
|
+
// Build table name with start, hash, and end
|
|
174
|
+
tableName = `${prefix}${pathStart}_${hash}_${pathEnd}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return tableName;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build an absolute path from base and relative component
|
|
184
|
+
*
|
|
185
|
+
* @param {string} basePath - Absolute base path
|
|
186
|
+
* @param {string} relativePath - Relative path from base (can be empty)
|
|
187
|
+
* @returns {string} Absolute path
|
|
188
|
+
*/
|
|
189
|
+
static buildAbsolutePath(basePath, relativePath = '') {
|
|
190
|
+
if (!relativePath || relativePath === '.') {
|
|
191
|
+
return basePath;
|
|
192
|
+
}
|
|
193
|
+
return path.resolve(basePath, relativePath);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validate that a path can be used for table name generation
|
|
198
|
+
*
|
|
199
|
+
* @param {string} pathStr - Path to validate
|
|
200
|
+
* @returns {Object} Validation result { valid: boolean, error?: string }
|
|
201
|
+
*/
|
|
202
|
+
static validatePath(pathStr) {
|
|
203
|
+
if (!pathStr || typeof pathStr !== 'string') {
|
|
204
|
+
return { valid: false, error: 'Path is required and must be a string' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pathStr.trim() === '') {
|
|
208
|
+
return { valid: false, error: 'Path cannot be empty' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check for null bytes (security)
|
|
212
|
+
if (pathStr.includes('\0')) {
|
|
213
|
+
return { valid: false, error: 'Path cannot contain null bytes' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { valid: true };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default PathNormalizer;
|