@arela/uploader 1.0.2 → 1.0.3

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.
@@ -0,0 +1,473 @@
1
+ import cliProgress from 'cli-progress';
2
+ import FormData from 'form-data';
3
+ import fs from 'fs';
4
+ import fetch from 'node-fetch';
5
+ import path from 'path';
6
+
7
+ import logger from '../services/LoggingService.js';
8
+ import ScanApiService from '../services/ScanApiService.js';
9
+
10
+ import appConfig from '../config/config.js';
11
+
12
+ /**
13
+ * Push Command Handler
14
+ * Uploads files with arela_path to storage API
15
+ */
16
+ export class PushCommand {
17
+ constructor() {
18
+ this.scanApiService = new ScanApiService();
19
+ }
20
+
21
+ /**
22
+ * Execute the push command
23
+ * @param {Object} options - Command options
24
+ */
25
+ async execute(options) {
26
+ try {
27
+ console.log('\nšŸš€ Starting arela push command\n');
28
+
29
+ // Validate scan configuration (same config as scan/identify/propagate)
30
+ const errors = this.#validateConfig();
31
+ if (errors.length > 0) {
32
+ console.error('āš ļø Configuration errors:');
33
+ errors.forEach((err) => console.error(` - ${err}`));
34
+ process.exit(1);
35
+ }
36
+
37
+ // Get configuration
38
+ const scanConfig = appConfig.getScanConfig();
39
+ const pushConfig = appConfig.getPushConfig();
40
+ const tableName = scanConfig.tableName;
41
+
42
+ // Set API target for scan/push operations
43
+ const scanApiTarget = options.api || options.scanApi || 'default';
44
+ const pushApiTarget = options.pushApi || scanApiTarget;
45
+
46
+ if (scanApiTarget !== 'default') {
47
+ appConfig.setApiTarget(scanApiTarget);
48
+ this.scanApiService = new ScanApiService(); // Reinitialize with new target
49
+ }
50
+
51
+ // Get upload API configuration
52
+ const uploadApiConfig = appConfig.getApiConfig(pushApiTarget);
53
+
54
+ console.log(`šŸŽÆ Scan API Target: ${scanApiTarget}`);
55
+ console.log(
56
+ `šŸŽÆ Upload API Target: ${pushApiTarget} → ${uploadApiConfig.baseUrl}`,
57
+ );
58
+ console.log(`šŸ“¦ Fetch Batch Size: ${options.batchSize}`);
59
+ console.log(`šŸ“¤ Upload Batch Size: ${options.uploadBatchSize}`);
60
+
61
+ // Apply filters
62
+ const filters = {
63
+ rfcs: options.rfcs || pushConfig.rfcs || [],
64
+ years: options.years || pushConfig.years || [],
65
+ };
66
+
67
+ if (filters.rfcs.length > 0) {
68
+ console.log(`šŸ” RFC Filter: ${filters.rfcs.join(', ')}`);
69
+ }
70
+ if (filters.years.length > 0) {
71
+ console.log(`šŸ” Year Filter: ${filters.years.join(', ')}`);
72
+ }
73
+
74
+ // Fetch all tables for this instance
75
+ console.log('\\nšŸ“Š Fetching instance tables...');
76
+ const tables = await this.scanApiService.getInstanceTables(
77
+ scanConfig.companySlug,
78
+ scanConfig.serverId,
79
+ scanConfig.basePathLabel,
80
+ );
81
+
82
+ if (tables.length === 0) {
83
+ console.error(
84
+ '\\nāŒ No tables found for this instance. Run \"arela scan\" first.\\n',
85
+ );
86
+ process.exit(1);
87
+ }
88
+
89
+ console.log(
90
+ `šŸ“‹ Found ${tables.length} table${tables.length === 1 ? '' : 's'} to process:`,
91
+ );
92
+ for (const table of tables) {
93
+ console.log(` - ${table.tableName}`);
94
+ }
95
+
96
+ // Process each table
97
+ let totalResults = {
98
+ processed: 0,
99
+ uploaded: 0,
100
+ errors: 0,
101
+ startTime: Date.now(),
102
+ };
103
+
104
+ for (const table of tables) {
105
+ console.log(`\\nšŸš€ Processing table: ${table.tableName}\\n`);
106
+
107
+ // Get initial statistics for this table
108
+ const initialStats = await this.scanApiService.getPushStats(
109
+ table.tableName,
110
+ );
111
+ this.#displayStats(' Table Status', initialStats);
112
+
113
+ if (initialStats.pending === 0) {
114
+ console.log(' āœ… No files pending upload. Skipping.\\n');
115
+ continue;
116
+ }
117
+
118
+ console.log(
119
+ `\\n šŸš€ Uploading ${initialStats.pending} pending files...\\n`,
120
+ );
121
+
122
+ // Process files for this table
123
+ const results = await this.#processFiles(
124
+ table.tableName,
125
+ filters,
126
+ parseInt(options.batchSize),
127
+ parseInt(options.uploadBatchSize),
128
+ uploadApiConfig,
129
+ );
130
+
131
+ totalResults.processed += results.processed;
132
+ totalResults.uploaded += results.uploaded;
133
+ totalResults.errors += results.errors;
134
+
135
+ // Display results for this table
136
+ console.log('\\n šŸ“Š Table Results:');
137
+ console.log(` Files Processed: ${results.processed}`);
138
+ console.log(` Uploaded: ${results.uploaded}`);
139
+ console.log(` Errors: ${results.errors}\\n`);
140
+ }
141
+
142
+ // Display combined results
143
+ const duration = ((Date.now() - totalResults.startTime) / 1000).toFixed(
144
+ 1,
145
+ );
146
+ const speed =
147
+ totalResults.processed > 0
148
+ ? (totalResults.processed / parseFloat(duration)).toFixed(0)
149
+ : 0;
150
+
151
+ console.log('\\nāœ… Push Complete!\\n');
152
+ console.log('šŸ“Š Total Results:');
153
+ console.log(` Tables Processed: ${tables.length}`);
154
+ console.log(` Files Processed: ${totalResults.processed}`);
155
+ console.log(` Uploaded: ${totalResults.uploaded}`);
156
+ console.log(` Errors: ${totalResults.errors}`);
157
+ console.log(` Duration: ${duration}s`);
158
+ console.log(` Speed: ${speed} files/sec\\n`);
159
+ } catch (error) {
160
+ console.error('\nāŒ Push failed:', error.message);
161
+ logger.error('Push command error:', error);
162
+ process.exit(1);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Validate configuration
168
+ * @private
169
+ * @returns {string[]} Array of error messages
170
+ */
171
+ #validateConfig() {
172
+ const errors = [];
173
+
174
+ try {
175
+ appConfig.validateScanConfig();
176
+ } catch (err) {
177
+ return [err.message];
178
+ }
179
+
180
+ const scanConfig = appConfig.getScanConfig();
181
+ if (!scanConfig.tableName) {
182
+ errors.push('Could not generate table name from configuration');
183
+ }
184
+
185
+ return errors;
186
+ }
187
+
188
+ /**
189
+ * Display statistics
190
+ * @private
191
+ */
192
+ #displayStats(title, stats) {
193
+ console.log(`\nšŸ“ˆ ${title}:`);
194
+ console.log(` Total with arela_path: ${stats.totalWithArelaPath}`);
195
+ console.log(` Uploaded: ${stats.uploaded}`);
196
+ console.log(` Pending: ${stats.pending}`);
197
+ if (stats.errors > 0) {
198
+ console.log(` Errors: ${stats.errors}`);
199
+ }
200
+ if (stats.maxAttemptsReached > 0) {
201
+ console.log(` Max Attempts Reached: ${stats.maxAttemptsReached}`);
202
+ }
203
+
204
+ // Show top RFCs if available
205
+ if (stats.byRfc && stats.byRfc.length > 0) {
206
+ console.log('\n šŸ“Š Top RFCs:');
207
+ stats.byRfc.slice(0, 5).forEach((rfc) => {
208
+ const percent = ((rfc.uploaded / rfc.total) * 100).toFixed(1);
209
+ console.log(
210
+ ` ${rfc.rfc}: ${rfc.uploaded}/${rfc.total} (${percent}%)`,
211
+ );
212
+ });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Process files in batches
218
+ * @private
219
+ */
220
+ async #processFiles(
221
+ tableName,
222
+ filters,
223
+ batchSize,
224
+ uploadBatchSize,
225
+ uploadApiConfig,
226
+ ) {
227
+ const results = {
228
+ processed: 0,
229
+ uploaded: 0,
230
+ errors: 0,
231
+ startTime: Date.now(),
232
+ };
233
+
234
+ // Get total count first for accurate progress
235
+ const initialStats = await this.scanApiService.getPushStats(tableName);
236
+ const totalToProcess = initialStats.pending;
237
+
238
+ let hasMore = true;
239
+
240
+ // Create progress bar with known total
241
+ const progressBar = new cliProgress.SingleBar({
242
+ format:
243
+ 'šŸ“¤ |{bar}| {percentage}% | {value}/{total} | {speed}/s | āœ“{uploaded} āœ—{errors}',
244
+ barCompleteChar: 'ā–ˆ',
245
+ barIncompleteChar: 'ā–‘',
246
+ hideCursor: true,
247
+ clearOnComplete: false,
248
+ stopOnComplete: true,
249
+ });
250
+
251
+ // Always use offset=0 because uploaded files are removed from pending query results
252
+ // After each batch upload, those files are no longer "pending", so the next query
253
+ // at offset=0 will naturally return the next batch of unprocessed files
254
+
255
+ // Start progress bar with known total
256
+ progressBar.start(totalToProcess, 0, {
257
+ speed: 0,
258
+ uploaded: 0,
259
+ errors: 0,
260
+ });
261
+
262
+ while (hasMore) {
263
+ // Fetch batch of files (always offset=0 since uploaded files are removed from pending)
264
+ const files = await this.scanApiService.fetchFilesForPush(tableName, {
265
+ ...filters,
266
+ offset: 0,
267
+ limit: batchSize,
268
+ });
269
+
270
+ if (files.length === 0) {
271
+ hasMore = false;
272
+ break;
273
+ }
274
+
275
+ // Upload files in smaller batches
276
+ for (let i = 0; i < files.length; i += uploadBatchSize) {
277
+ const uploadBatch = files.slice(i, i + uploadBatchSize);
278
+ const batchResults = await this.#uploadBatch(
279
+ uploadBatch,
280
+ uploadApiConfig,
281
+ );
282
+
283
+ // Update results in database
284
+ await this.scanApiService.batchUpdateUpload(tableName, batchResults);
285
+
286
+ // Update counters
287
+ batchResults.forEach((result) => {
288
+ results.processed++;
289
+ if (result.uploaded) {
290
+ results.uploaded++;
291
+ } else {
292
+ results.errors++;
293
+ }
294
+ });
295
+
296
+ // Update progress bar
297
+ const elapsed = (Date.now() - results.startTime) / 1000;
298
+ const speed =
299
+ elapsed > 0 ? (results.processed / elapsed).toFixed(0) : 0;
300
+ progressBar.update(results.processed, {
301
+ speed,
302
+ uploaded: results.uploaded,
303
+ errors: results.errors,
304
+ });
305
+ }
306
+
307
+ // Check if there are more files
308
+ // If we got fewer files than requested, we've processed all pending files
309
+ if (files.length < batchSize) {
310
+ hasMore = false;
311
+ }
312
+ }
313
+
314
+ progressBar.stop();
315
+
316
+ return results;
317
+ }
318
+
319
+ /**
320
+ * Upload a batch of files
321
+ * @private
322
+ */
323
+ async #uploadBatch(files, uploadApiConfig) {
324
+ const uploadPromises = files.map((file) =>
325
+ this.#uploadFile(file, uploadApiConfig),
326
+ );
327
+ return Promise.all(uploadPromises);
328
+ }
329
+
330
+ /**
331
+ * Upload a single file
332
+ * @private
333
+ */
334
+ async #uploadFile(file, uploadApiConfig) {
335
+ const result = {
336
+ id: file.id,
337
+ uploaded: false,
338
+ uploadError: null,
339
+ uploadPath: null,
340
+ uploadedToStorageId: null,
341
+ };
342
+
343
+ try {
344
+ // Check if file exists
345
+ if (!fs.existsSync(file.absolute_path)) {
346
+ result.uploadError =
347
+ 'FILE_NOT_FOUND: File does not exist on filesystem';
348
+ return result;
349
+ }
350
+
351
+ // Get file stats
352
+ const stats = fs.statSync(file.absolute_path);
353
+ if (!stats.isFile()) {
354
+ result.uploadError = 'NOT_A_FILE: Path is not a regular file';
355
+ return result;
356
+ }
357
+
358
+ // Construct upload path using arela_path
359
+ // arela_path format: RFC/Year/Patente/Aduana/Pedimento/
360
+ const uploadPath = `${file.arela_path}${file.file_name}`;
361
+ result.uploadPath = uploadPath;
362
+
363
+ // Upload file using storage API
364
+ const response = await this.#uploadToStorageApi(
365
+ file.absolute_path,
366
+ uploadPath,
367
+ uploadApiConfig,
368
+ {
369
+ rfc: file.rfc,
370
+ year: file.detected_pedimento_year,
371
+ originalPath: file.relative_path,
372
+ },
373
+ );
374
+
375
+ if (response.success) {
376
+ result.uploaded = true;
377
+ result.uploadedToStorageId = response.fileId;
378
+ logger.info(`āœ“ Uploaded: ${file.file_name} → ${uploadPath}`);
379
+ } else {
380
+ result.uploadError = `UPLOAD_FAILED: ${response.error || 'Unknown error'}`;
381
+ logger.error(`āœ— Failed: ${file.file_name} - ${result.uploadError}`);
382
+ }
383
+ } catch (error) {
384
+ result.uploadError = `UPLOAD_ERROR: ${error.message}`;
385
+ logger.error(`āœ— Error uploading ${file.file_name}:`, error.message);
386
+ }
387
+
388
+ return result;
389
+ }
390
+
391
+ /**
392
+ * Upload file to storage API
393
+ * @private
394
+ */
395
+ async #uploadToStorageApi(filePath, uploadPath, apiConfig, metadata = {}) {
396
+ try {
397
+ // Create form data
398
+ const form = new FormData();
399
+ form.append('files', fs.createReadStream(filePath));
400
+
401
+ // uploadPath format: RFC/Year/Patente/Aduana/Pedimento/filename
402
+ // Extract folder structure (arela_path without filename)
403
+ const folderStructure = path.dirname(uploadPath);
404
+
405
+ // Use batch-upload-and-process endpoint (same as legacy upload)
406
+ form.append('bucket', appConfig.getPushConfig().bucket);
407
+ form.append('folderStructure', folderStructure);
408
+
409
+ // Add RFC for multi-database routing
410
+ if (metadata.rfc) {
411
+ form.append('rfc', metadata.rfc);
412
+ }
413
+
414
+ // Enable auto-detection
415
+ form.append('autoDetect', 'true');
416
+ form.append('autoOrganize', 'false');
417
+ form.append('batchSize', '1');
418
+ form.append('clientVersion', appConfig.packageVersion);
419
+
420
+ // Upload file
421
+ const response = await fetch(
422
+ `${apiConfig.baseUrl}/api/storage/batch-upload-and-process`,
423
+ {
424
+ method: 'POST',
425
+ headers: {
426
+ 'x-api-key': apiConfig.token,
427
+ ...form.getHeaders(),
428
+ },
429
+ body: form,
430
+ },
431
+ );
432
+
433
+ if (!response.ok) {
434
+ const errorText = await response.text();
435
+ return {
436
+ success: false,
437
+ error: `HTTP ${response.status}: ${errorText}`,
438
+ };
439
+ }
440
+
441
+ const result = await response.json();
442
+
443
+ // batch-upload-and-process returns { uploaded: [], detected: [], errors: [] }
444
+ // Check if upload was successful
445
+ if (result.uploaded && result.uploaded.length > 0) {
446
+ const uploadedFile = result.uploaded[0];
447
+ return {
448
+ success: true,
449
+ fileId: uploadedFile.fileId,
450
+ path: uploadedFile.path,
451
+ };
452
+ } else if (result.errors && result.errors.length > 0) {
453
+ const error = result.errors[0];
454
+ return {
455
+ success: false,
456
+ error: error.error || 'Upload failed',
457
+ };
458
+ } else {
459
+ return {
460
+ success: false,
461
+ error: 'Unknown upload error - no files uploaded',
462
+ };
463
+ }
464
+ } catch (error) {
465
+ return {
466
+ success: false,
467
+ error: error.message,
468
+ };
469
+ }
470
+ }
471
+ }
472
+
473
+ export default PushCommand;