@arela/uploader 1.0.2 → 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.
Files changed (61) hide show
  1. package/.env.local +316 -0
  2. package/.env.template +70 -0
  3. package/coverage/IdentifyCommand.js.html +1462 -0
  4. package/coverage/PropagateCommand.js.html +1507 -0
  5. package/coverage/PushCommand.js.html +1504 -0
  6. package/coverage/ScanCommand.js.html +1654 -0
  7. package/coverage/UploadCommand.js.html +1846 -0
  8. package/coverage/WatchCommand.js.html +4111 -0
  9. package/coverage/base.css +224 -0
  10. package/coverage/block-navigation.js +87 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +191 -0
  13. package/coverage/lcov-report/IdentifyCommand.js.html +1462 -0
  14. package/coverage/lcov-report/PropagateCommand.js.html +1507 -0
  15. package/coverage/lcov-report/PushCommand.js.html +1504 -0
  16. package/coverage/lcov-report/ScanCommand.js.html +1654 -0
  17. package/coverage/lcov-report/UploadCommand.js.html +1846 -0
  18. package/coverage/lcov-report/WatchCommand.js.html +4111 -0
  19. package/coverage/lcov-report/base.css +224 -0
  20. package/coverage/lcov-report/block-navigation.js +87 -0
  21. package/coverage/lcov-report/favicon.png +0 -0
  22. package/coverage/lcov-report/index.html +191 -0
  23. package/coverage/lcov-report/prettify.css +1 -0
  24. package/coverage/lcov-report/prettify.js +2 -0
  25. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  26. package/coverage/lcov-report/sorter.js +210 -0
  27. package/coverage/lcov.info +1937 -0
  28. package/coverage/prettify.css +1 -0
  29. package/coverage/prettify.js +2 -0
  30. package/coverage/sort-arrow-sprite.png +0 -0
  31. package/coverage/sorter.js +210 -0
  32. package/docs/API_RETRY_MECHANISM.md +338 -0
  33. package/docs/ARELA_IDENTIFY_IMPLEMENTATION.md +489 -0
  34. package/docs/ARELA_IDENTIFY_QUICKREF.md +186 -0
  35. package/docs/ARELA_PROPAGATE_IMPLEMENTATION.md +581 -0
  36. package/docs/ARELA_PROPAGATE_QUICKREF.md +272 -0
  37. package/docs/ARELA_PUSH_IMPLEMENTATION.md +577 -0
  38. package/docs/ARELA_PUSH_QUICKREF.md +322 -0
  39. package/docs/ARELA_SCAN_IMPLEMENTATION.md +373 -0
  40. package/docs/ARELA_SCAN_QUICKREF.md +139 -0
  41. package/docs/CROSS_PLATFORM_PATH_HANDLING.md +593 -0
  42. package/docs/DETECTION_ATTEMPT_TRACKING.md +414 -0
  43. package/docs/MIGRATION_UPLOADER_TO_FILE_STATS.md +1020 -0
  44. package/docs/MULTI_LEVEL_DIRECTORY_SCANNING.md +494 -0
  45. package/docs/STATS_COMMAND_SEQUENCE_DIAGRAM.md +287 -0
  46. package/docs/STATS_COMMAND_SIMPLE.md +93 -0
  47. package/package.json +31 -3
  48. package/src/commands/IdentifyCommand.js +459 -0
  49. package/src/commands/PropagateCommand.js +474 -0
  50. package/src/commands/PushCommand.js +473 -0
  51. package/src/commands/ScanCommand.js +523 -0
  52. package/src/config/config.js +154 -7
  53. package/src/file-detection.js +9 -10
  54. package/src/index.js +150 -0
  55. package/src/services/ScanApiService.js +645 -0
  56. package/src/utils/PathNormalizer.js +220 -0
  57. package/tests/commands/IdentifyCommand.test.js +570 -0
  58. package/tests/commands/PropagateCommand.test.js +568 -0
  59. package/tests/commands/PushCommand.test.js +754 -0
  60. package/tests/commands/ScanCommand.test.js +382 -0
  61. package/tests/unit/PathAndTableNameGeneration.test.js +1211 -0
@@ -0,0 +1,523 @@
1
+ import cliProgress from 'cli-progress';
2
+ import { globbyStream } from 'globby';
3
+ import path from 'path';
4
+ import { Transform } from 'stream';
5
+ import { pipeline } from 'stream/promises';
6
+
7
+ import logger from '../services/LoggingService.js';
8
+
9
+ import appConfig from '../config/config.js';
10
+ import ErrorHandler from '../errors/ErrorHandler.js';
11
+ import { ConfigurationError } from '../errors/ErrorTypes.js';
12
+ import FileOperations from '../utils/FileOperations.js';
13
+ import PathNormalizer from '../utils/PathNormalizer.js';
14
+
15
+ /**
16
+ * Scan Command Handler
17
+ * Handles the optimized arela scan command with streaming support
18
+ */
19
+ export class ScanCommand {
20
+ constructor() {
21
+ this.errorHandler = new ErrorHandler(logger);
22
+ this.scanApiService = null; // Will be initialized in execute
23
+ }
24
+
25
+ /**
26
+ * Execute the scan command
27
+ * @param {Object} options - Command options
28
+ * @param {boolean} options.countFirst - Count files first for percentage-based progress
29
+ */
30
+ async execute(options = {}) {
31
+ const startTime = Date.now();
32
+
33
+ try {
34
+ // Validate scan configuration
35
+ appConfig.validateScanConfig();
36
+
37
+ // Import ScanApiService dynamically
38
+ const { default: ScanApiService } = await import(
39
+ '../services/ScanApiService.js'
40
+ );
41
+ this.scanApiService = new ScanApiService();
42
+
43
+ const scanConfig = appConfig.getScanConfig();
44
+ // Ensure basePath is absolute for scan operations
45
+ const basePath = PathNormalizer.toAbsolutePath(appConfig.getBasePath());
46
+
47
+ logger.info('🔍 Starting arela scan command');
48
+ logger.info(`📦 Company: ${scanConfig.companySlug}`);
49
+ logger.info(`🖥️ Server: ${scanConfig.serverId}`);
50
+ logger.info(`📂 Base Path: ${basePath}`);
51
+ logger.info(`📊 Directory Level: ${scanConfig.directoryLevel}`);
52
+
53
+ // Step 1: Discover directories at specified level
54
+ logger.info('\n🔍 Discovering directories...');
55
+ const directories = await this.#discoverDirectories(
56
+ basePath,
57
+ scanConfig.directoryLevel,
58
+ );
59
+ logger.info(
60
+ `📁 Found ${directories.length} director${directories.length === 1 ? 'y' : 'ies'} to scan`,
61
+ );
62
+
63
+ // Step 2: Register instances for each directory
64
+ logger.info('\n📝 Registering scan instances...');
65
+ const registrations = [];
66
+ for (const dir of directories) {
67
+ // dir.path is already absolute from #discoverDirectories
68
+ // Use the absolute path as basePathLabel (simplifies everything!)
69
+ const absolutePath = dir.path;
70
+
71
+ const registration = await this.scanApiService.registerInstance({
72
+ companySlug: scanConfig.companySlug,
73
+ serverId: scanConfig.serverId,
74
+ basePathFull: absolutePath,
75
+ });
76
+
77
+ registrations.push({ ...registration, directory: dir });
78
+
79
+ if (registration.existed) {
80
+ logger.info(` ✓ ${dir.label || 'root'}: ${registration.tableName}`);
81
+ } else {
82
+ logger.success(
83
+ ` ✓ ${dir.label || 'root'}: ${registration.tableName} (new)`,
84
+ );
85
+ }
86
+ }
87
+
88
+ // Optional: Count files first for percentage-based progress
89
+ let totalFiles = null;
90
+ if (options.countFirst) {
91
+ logger.info('\n🔢 Counting files...');
92
+ totalFiles = await this.#countFiles(basePath, scanConfig);
93
+ logger.info(`📊 Found ${totalFiles.toLocaleString()} files to scan`);
94
+ }
95
+
96
+ // Step 3: Stream files and upload stats for each directory
97
+ logger.info('\n🚀 Starting file scan...');
98
+ let totalStats = {
99
+ filesScanned: 0,
100
+ filesInserted: 0,
101
+ filesSkipped: 0,
102
+ totalSize: 0,
103
+ };
104
+
105
+ for (const reg of registrations) {
106
+ logger.info(`\n📂 Scanning: ${reg.directory.label || 'root'}`);
107
+ const stats = await this.#streamScanDirectory(
108
+ reg.directory.path,
109
+ scanConfig,
110
+ reg.tableName,
111
+ null, // Don't use percentage for individual directories
112
+ );
113
+
114
+ // Step 4: Complete scan for this directory
115
+ await this.scanApiService.completeScan({
116
+ tableName: reg.tableName,
117
+ totalFiles: stats.filesScanned,
118
+ totalSizeBytes: stats.totalSize,
119
+ });
120
+
121
+ totalStats.filesScanned += stats.filesScanned;
122
+ totalStats.filesInserted += stats.filesInserted;
123
+ totalStats.filesSkipped += stats.filesSkipped;
124
+ totalStats.totalSize += stats.totalSize;
125
+ }
126
+
127
+ const duration = ((Date.now() - startTime) / 1000).toFixed(2);
128
+ const filesPerSec = (totalStats.filesScanned / duration).toFixed(2);
129
+
130
+ logger.success('\n✅ Scan completed successfully!');
131
+ logger.info(`\n📊 Scan Statistics:`);
132
+ logger.info(` Directories scanned: ${registrations.length}`);
133
+ logger.info(
134
+ ` Files scanned: ${totalStats.filesScanned.toLocaleString()}`,
135
+ );
136
+ logger.info(
137
+ ` Files inserted: ${totalStats.filesInserted.toLocaleString()}`,
138
+ );
139
+ logger.info(
140
+ ` Files skipped: ${totalStats.filesSkipped.toLocaleString()} (excluded patterns)`,
141
+ );
142
+ logger.info(` Total size: ${this.#formatBytes(totalStats.totalSize)}`);
143
+ logger.info(` Duration: ${duration}s`);
144
+ logger.info(` Throughput: ${filesPerSec} files/sec`);
145
+ logger.info(`\n📋 Tables created:`);
146
+ for (const reg of registrations) {
147
+ logger.info(` - ${reg.tableName}`);
148
+ }
149
+
150
+ return {
151
+ success: true,
152
+ tables: registrations.map((r) => r.tableName),
153
+ stats: totalStats,
154
+ };
155
+ } catch (error) {
156
+ this.errorHandler.handleError(error, 'scan');
157
+ return {
158
+ success: false,
159
+ error: error.message,
160
+ stats: {
161
+ filesScanned: 0,
162
+ filesInserted: 0,
163
+ filesSkipped: 0,
164
+ totalSize: 0,
165
+ },
166
+ };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Discover directories at specified level
172
+ * @private
173
+ */
174
+ async #discoverDirectories(basePath, level) {
175
+ // Get sources, defaults to ['.'] if not configured
176
+ const sources = appConfig.getUploadSources();
177
+ const isDefaultSource = sources.length === 1 && sources[0] === '.';
178
+
179
+ if (level === 0) {
180
+ // Level 0: Create one entry per source
181
+ return sources.map((source) => {
182
+ const sourcePath =
183
+ source === '.' ? basePath : path.resolve(basePath, source);
184
+ // Label is relative path for display purposes only
185
+ const label = source === '.' ? '' : source;
186
+ return { path: sourcePath, label };
187
+ });
188
+ }
189
+
190
+ // For level > 0: First discover directories at the base path, then combine with sources
191
+ const fs = await import('fs/promises');
192
+ const directories = [];
193
+
194
+ try {
195
+ const fs = await import('fs/promises');
196
+
197
+ // Step 1: Discover directories at the specified level from base path
198
+ const levelDirs = await this.#getDirectoriesAtLevel(basePath, level, '');
199
+
200
+ // Step 2: For each discovered directory, create entries for each source
201
+ for (const levelDir of levelDirs) {
202
+ for (const source of sources) {
203
+ if (source === '.') {
204
+ // Source is current directory, use discovered path as-is
205
+ directories.push(levelDir);
206
+ } else {
207
+ // Append source to path
208
+ const combinedPath = path.resolve(levelDir.path, source);
209
+
210
+ // Only add if the combined path actually exists
211
+ try {
212
+ const stats = await fs.stat(combinedPath);
213
+ if (stats.isDirectory()) {
214
+ // Label for display
215
+ const label = levelDir.label
216
+ ? `${levelDir.label}/${source}`
217
+ : source;
218
+ directories.push({
219
+ path: combinedPath,
220
+ label,
221
+ });
222
+ } else {
223
+ logger.debug(`⏭️ Skipping ${combinedPath} (not a directory)`);
224
+ }
225
+ } catch (error) {
226
+ logger.debug(`⏭️ Skipping ${combinedPath} (does not exist)`);
227
+ }
228
+ }
229
+ }
230
+ }
231
+ } catch (error) {
232
+ logger.warn(`⚠️ Could not discover directories: ${error.message}`);
233
+ }
234
+
235
+ return directories;
236
+ }
237
+
238
+ /**
239
+ * Recursively get directories at specified level
240
+ * @private
241
+ */
242
+ async #getDirectoriesAtLevel(
243
+ basePath,
244
+ targetLevel,
245
+ currentPath,
246
+ currentLevel = 0,
247
+ ) {
248
+ const fs = await import('fs/promises');
249
+ const fullPath = path.join(basePath, currentPath);
250
+
251
+ if (currentLevel === targetLevel) {
252
+ // Label is the relative path for display
253
+ const label = currentPath || '';
254
+ return [{ path: fullPath, label }];
255
+ }
256
+
257
+ const directories = [];
258
+ const entries = await fs.readdir(fullPath, { withFileTypes: true });
259
+
260
+ for (const entry of entries) {
261
+ if (entry.isDirectory()) {
262
+ const subPath = path.join(currentPath, entry.name);
263
+ const subDirs = await this.#getDirectoriesAtLevel(
264
+ basePath,
265
+ targetLevel,
266
+ subPath,
267
+ currentLevel + 1,
268
+ );
269
+ directories.push(...subDirs);
270
+ }
271
+ }
272
+
273
+ return directories;
274
+ }
275
+
276
+ /**
277
+ * Count files for percentage-based progress
278
+ * @private
279
+ */
280
+ async #countFiles(basePath, scanConfig) {
281
+ const sources = appConfig.getUploadSources();
282
+ let totalCount = 0;
283
+
284
+ for (const source of sources) {
285
+ const sourcePath = path.resolve(basePath, source);
286
+ const files = await globbyStream('**/*', {
287
+ cwd: sourcePath,
288
+ onlyFiles: true,
289
+ absolute: true,
290
+ });
291
+
292
+ for await (const file of files) {
293
+ if (!this.#shouldExcludeFile(file, scanConfig.excludePatterns)) {
294
+ totalCount++;
295
+ }
296
+ }
297
+ }
298
+
299
+ return totalCount;
300
+ }
301
+
302
+ /**
303
+ * Stream files from a single directory and upload stats in batches
304
+ * @private
305
+ */
306
+ async #streamScanDirectory(
307
+ dirPath,
308
+ scanConfig,
309
+ tableName,
310
+ totalFiles = null,
311
+ ) {
312
+ // For directory-level scanning, we scan the directory directly
313
+ const batchSize = scanConfig.batchSize || 2000;
314
+ const scanTimestamp = new Date().toISOString();
315
+
316
+ let filesScanned = 0;
317
+ let filesInserted = 0;
318
+ let filesSkipped = 0;
319
+ let totalSize = 0;
320
+ let currentBatch = [];
321
+
322
+ // Create progress bar
323
+ const progressBar = this.#createProgressBar(totalFiles);
324
+
325
+ try {
326
+ // Create stream with stats option
327
+ const fileStream = globbyStream('**/*', {
328
+ cwd: dirPath,
329
+ onlyFiles: true,
330
+ absolute: true,
331
+ stats: true, // Get file stats during discovery
332
+ });
333
+
334
+ // Process each file from stream
335
+ for await (const entry of fileStream) {
336
+ // globby with stats:true returns {path, stats} objects
337
+ const filePath = typeof entry === 'string' ? entry : entry.path;
338
+ const stats = typeof entry === 'object' ? entry.stats : null;
339
+
340
+ // Check if file should be excluded
341
+ if (this.#shouldExcludeFile(filePath, scanConfig.excludePatterns)) {
342
+ filesSkipped++;
343
+ continue;
344
+ }
345
+
346
+ // Get file stats (use from globby or fetch manually)
347
+ const fileStats = stats || FileOperations.getFileStats(filePath);
348
+ if (!fileStats) {
349
+ logger.debug(`⚠️ Could not read stats: ${filePath}`);
350
+ filesSkipped++;
351
+ continue;
352
+ }
353
+
354
+ // Normalize file record
355
+ const record = this.#normalizeFileRecord(
356
+ filePath,
357
+ fileStats,
358
+ dirPath,
359
+ scanTimestamp,
360
+ );
361
+
362
+ currentBatch.push(record);
363
+ filesScanned++;
364
+ totalSize += record.sizeBytes;
365
+
366
+ // Update progress
367
+ if (totalFiles) {
368
+ progressBar.update(filesScanned);
369
+ } else {
370
+ // Show throughput instead of percentage
371
+ const elapsed = (Date.now() - progressBar.startTime) / 1000;
372
+ const rate = (filesScanned / elapsed).toFixed(1);
373
+ progressBar.update(filesScanned, { rate });
374
+ }
375
+
376
+ // Upload batch when full
377
+ if (currentBatch.length >= batchSize) {
378
+ const inserted = await this.#uploadBatch(tableName, currentBatch);
379
+ filesInserted += inserted;
380
+ currentBatch = [];
381
+ }
382
+ }
383
+ } catch (error) {
384
+ logger.error(`❌ Error scanning directory: ${error.message}`);
385
+ }
386
+
387
+ // Upload remaining files
388
+ if (currentBatch.length > 0) {
389
+ const inserted = await this.#uploadBatch(tableName, currentBatch);
390
+ filesInserted += inserted;
391
+ }
392
+
393
+ progressBar.stop();
394
+
395
+ return {
396
+ filesScanned,
397
+ filesInserted,
398
+ filesSkipped,
399
+ totalSize,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Upload a batch of file records
405
+ * @private
406
+ */
407
+ async #uploadBatch(tableName, records) {
408
+ try {
409
+ const result = await this.scanApiService.batchInsertStats(
410
+ tableName,
411
+ records,
412
+ );
413
+ return result.inserted;
414
+ } catch (error) {
415
+ logger.error(`❌ Failed to upload batch: ${error.message}`);
416
+ return 0;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Normalize file record for database insertion
422
+ * Stores paths with forward slashes for consistency but keeps them absolute
423
+ * @private
424
+ */
425
+ #normalizeFileRecord(filePath, fileStats, basePath, scanTimestamp) {
426
+ const fileName = path.basename(filePath);
427
+ const fileExtension = path.extname(filePath).toLowerCase().replace('.', '');
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);
435
+
436
+ return {
437
+ fileName,
438
+ fileExtension,
439
+ directoryPath,
440
+ relativePath,
441
+ absolutePath,
442
+ sizeBytes: Number(fileStats.size),
443
+ modifiedAt: fileStats.mtime.toISOString(),
444
+ scanTimestamp,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Check if file should be excluded based on patterns
450
+ * @private
451
+ */
452
+ #shouldExcludeFile(filePath, excludePatterns) {
453
+ const fileName = path.basename(filePath);
454
+
455
+ for (const pattern of excludePatterns) {
456
+ // Convert glob pattern to regex
457
+ const regexPattern = pattern
458
+ .replace(/\./g, '\\.') // Escape dots
459
+ .replace(/\*/g, '.*') // * to .*
460
+ .replace(/\?/g, '.'); // ? to .
461
+
462
+ const regex = new RegExp(`^${regexPattern}$`, 'i');
463
+ if (regex.test(fileName)) {
464
+ return true;
465
+ }
466
+ }
467
+
468
+ return false;
469
+ }
470
+
471
+ /**
472
+ * Create progress bar
473
+ * @private
474
+ */
475
+ #createProgressBar(totalFiles) {
476
+ if (totalFiles) {
477
+ // Percentage-based progress
478
+ const bar = new cliProgress.SingleBar(
479
+ {
480
+ format:
481
+ '📊 Scanning |{bar}| {percentage}% | {value}/{total} files | {rate} files/sec',
482
+ barCompleteChar: '\u2588',
483
+ barIncompleteChar: '\u2591',
484
+ hideCursor: true,
485
+ },
486
+ cliProgress.Presets.shades_classic,
487
+ );
488
+ bar.start(totalFiles, 0, { rate: '0.0' });
489
+ bar.startTime = Date.now();
490
+ return bar;
491
+ } else {
492
+ // Throughput-based progress
493
+ const bar = new cliProgress.SingleBar(
494
+ {
495
+ format: '📊 Scanning | {value} files | {rate} files/sec',
496
+ hideCursor: true,
497
+ clearOnComplete: false,
498
+ stopOnComplete: false,
499
+ },
500
+ cliProgress.Presets.legacy,
501
+ );
502
+ bar.start(0, 0, { rate: '0.0' });
503
+ bar.startTime = Date.now();
504
+ return bar;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Format bytes to human-readable size
510
+ * @private
511
+ */
512
+ #formatBytes(bytes) {
513
+ if (bytes === 0) return '0 Bytes';
514
+
515
+ const k = 1024;
516
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
517
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
518
+
519
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
520
+ }
521
+ }
522
+
523
+ export default new ScanCommand();