@arela/uploader 1.0.15 → 1.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arela/uploader",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "CLI to upload files/directories to Arela",
5
5
  "bin": {
6
6
  "arela": "./src/index.js"
@@ -31,6 +31,7 @@
31
31
  "homepage": "https://github.com/inspiraCode/arela-uploader#readme",
32
32
  "dependencies": {
33
33
  "@supabase/supabase-js": "2.49.4",
34
+ "bullmq": "^5.71.0",
34
35
  "chokidar": "^4.0.3",
35
36
  "cli-progress": "3.12.0",
36
37
  "commander": "13.1.0",
@@ -38,6 +39,7 @@
38
39
  "form-data": "4.0.4",
39
40
  "formdata-node": "^6.0.3",
40
41
  "globby": "14.1.0",
42
+ "ioredis": "^5.10.0",
41
43
  "mime-types": "3.0.1",
42
44
  "node-fetch": "3.3.2",
43
45
  "office-text-extractor": "3.0.3",
@@ -34,9 +34,11 @@ export class IdentifyCommand {
34
34
  * @param {string} options.api - API target (default, agencia, cliente)
35
35
  * @param {number} options.batchSize - Batch size for API operations
36
36
  * @param {boolean} options.showStats - Show performance statistics
37
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
37
38
  */
38
39
  async execute(options = {}) {
39
40
  const startTime = Date.now();
41
+ this.onProgress = options.onProgress || null;
40
42
 
41
43
  try {
42
44
  // Validate scan configuration (need same config as scan command)
@@ -88,8 +90,19 @@ export class IdentifyCommand {
88
90
  pending: 0,
89
91
  };
90
92
 
91
- for (const table of tables) {
93
+ // Report initial progress
94
+ this.#reportProgress(
95
+ 0,
96
+ `Starting identification on ${tables.length} tables`,
97
+ );
98
+
99
+ for (let i = 0; i < tables.length; i++) {
100
+ const table = tables[i];
92
101
  logger.info(`\nšŸ” Processing table: ${table.tableName}`);
102
+ this.#reportProgress(
103
+ Math.round((i / tables.length) * 100),
104
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
105
+ );
93
106
 
94
107
  // Get detection statistics for this table
95
108
  const stats = await this.#processTable(
@@ -108,6 +121,12 @@ export class IdentifyCommand {
108
121
  const avgSpeed =
109
122
  duration > 0 ? Math.round(totalStats.processed / duration) : 0;
110
123
 
124
+ // Report completion
125
+ this.#reportProgress(
126
+ 100,
127
+ `Identification completed: ${totalStats.detected} pedimentos detected`,
128
+ );
129
+
111
130
  logger.success(`\nāœ… Identification Complete!`);
112
131
  logger.info(`\nšŸ“Š Total Results:`);
113
132
  logger.info(` Tables Processed: ${tables.length}`);
@@ -479,6 +498,22 @@ export class IdentifyCommand {
479
498
  ` Heap Total: ${(memUsage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
480
499
  );
481
500
  }
501
+
502
+ /**
503
+ * Report progress to callback if available (for worker mode)
504
+ * @private
505
+ * @param {number} percent - Progress percentage (0-100)
506
+ * @param {string} message - Progress message
507
+ */
508
+ #reportProgress(percent, message) {
509
+ if (this.onProgress && typeof this.onProgress === 'function') {
510
+ try {
511
+ this.onProgress(percent, message);
512
+ } catch (error) {
513
+ logger.debug(`Progress callback error: ${error.message}`);
514
+ }
515
+ }
516
+ }
482
517
  }
483
518
 
484
519
  // Export singleton instance
@@ -0,0 +1,414 @@
1
+ import logger from '../services/LoggingService.js';
2
+ import { PipelineApiService } from '../services/PipelineApiService.js';
3
+
4
+ import appConfig from '../config/config.js';
5
+ import ErrorHandler from '../errors/ErrorHandler.js';
6
+
7
+ /**
8
+ * Poll Worker Command Handler
9
+ * Runs arela-uploader as an HTTP-polling worker, fetching jobs from the API.
10
+ * Alternative to BullMQ/Redis-based worker for environments without Redis access.
11
+ *
12
+ * Usage:
13
+ * arela worker --poll
14
+ * arela worker --poll --interval 10000
15
+ *
16
+ * Environment variables:
17
+ * WORKER_POLL_INTERVAL - Polling interval in ms (default: 5000)
18
+ * WORKER_POLL_API_TARGET - API target for polling (default: 'agencia')
19
+ * ARELA_SERVER_ID - Server identifier for job assignment
20
+ */
21
+ export class PollWorkerCommand {
22
+ constructor() {
23
+ this.errorHandler = new ErrorHandler(logger);
24
+ this.isShuttingDown = false;
25
+ this.currentJob = null;
26
+ this.pipelineApi = null;
27
+ this.pollTimer = null;
28
+ }
29
+
30
+ /**
31
+ * Execute the poll worker command
32
+ * @param {Object} options - Command options
33
+ * @param {number} options.interval - Polling interval in milliseconds
34
+ * @param {string} options.api - API target for polling
35
+ */
36
+ async execute(options = {}) {
37
+ try {
38
+ const workerConfig = appConfig.getWorkerConfig();
39
+ const pollConfig = workerConfig.poll;
40
+
41
+ // Get configuration
42
+ const interval = parseInt(options.interval) || pollConfig.interval;
43
+ const apiTarget = options.api || pollConfig.apiTarget;
44
+ const serverId = appConfig.getServerId();
45
+
46
+ if (!serverId) {
47
+ throw new Error(
48
+ 'ARELA_SERVER_ID is required for poll mode. Set it in your .env file.',
49
+ );
50
+ }
51
+
52
+ this.serverId = serverId;
53
+
54
+ // Initialize Pipeline API service
55
+ this.pipelineApi = new PipelineApiService(apiTarget);
56
+
57
+ console.log('\nšŸ”§ Starting Arela Poll Worker');
58
+ console.log(`šŸ“” API Target: ${apiTarget}`);
59
+ console.log(`šŸ–„ļø Server ID: ${serverId}`);
60
+ console.log(`ā±ļø Poll Interval: ${interval}ms`);
61
+
62
+ logger.info('šŸ”§ Starting Arela Poll Worker');
63
+ logger.info(`šŸ“” API Target: ${apiTarget}`);
64
+ logger.info(`šŸ–„ļø Server ID: ${serverId}`);
65
+ logger.info(`ā±ļø Poll Interval: ${interval}ms`);
66
+
67
+ // Setup graceful shutdown handlers
68
+ this.#setupShutdownHandlers();
69
+
70
+ // Start polling loop
71
+ await this.#startPolling(serverId, interval);
72
+ } catch (error) {
73
+ logger.error('āŒ Poll Worker failed to start:', error.message);
74
+ throw error;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Start the polling loop
80
+ * @private
81
+ */
82
+ async #startPolling(serverId, interval) {
83
+ console.log('\nāœ… Poll Worker is running. Press Ctrl+C to stop.\n');
84
+ logger.success('Poll Worker is running');
85
+
86
+ while (!this.isShuttingDown) {
87
+ try {
88
+ // Check for next job
89
+ const job = await this.pipelineApi.getNextJob(serverId);
90
+
91
+ if (job && job.id) {
92
+ console.log(`šŸ“„ Got job: ${job.type} for RFC ${job.rfc}`);
93
+ logger.info(`šŸ“„ Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`);
94
+ await this.#processJob(job);
95
+ } else {
96
+ // Only log to file, not console (too noisy)
97
+ logger.debug('šŸ“­ No jobs available, waiting...');
98
+ }
99
+ } catch (error) {
100
+ console.error(`āŒ Polling error: ${error.message}`);
101
+ logger.error(`āŒ Polling error: ${error.message}`);
102
+ }
103
+
104
+ // Wait for next poll (unless shutting down)
105
+ if (!this.isShuttingDown) {
106
+ await this.#sleep(interval);
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Process a job
113
+ * @private
114
+ */
115
+ async #processJob(job) {
116
+ this.currentJob = job;
117
+
118
+ // Start heartbeat timer to keep worker alive during long-running jobs
119
+ const heartbeatInterval = setInterval(async () => {
120
+ try {
121
+ await this.pipelineApi.sendHeartbeat(this.serverId, 'busy');
122
+ logger.debug(`šŸ’“ Heartbeat sent for ${this.serverId}`);
123
+ } catch {
124
+ // Non-critical — progress updates also refresh heartbeat
125
+ }
126
+ }, 20000);
127
+
128
+ try {
129
+ logger.info(`šŸ”„ Processing ${job.type} job ${job.id}`);
130
+
131
+ // DEBUG: Delay to observe busy status in UI (remove in production)
132
+ const debugDelay = parseInt(process.env.DEBUG_JOB_DELAY) || 0;
133
+ if (debugDelay > 0) {
134
+ console.log(
135
+ `ā³ DEBUG: Waiting ${debugDelay / 1000}s before executing job...`,
136
+ );
137
+ await this.#sleep(debugDelay);
138
+ }
139
+
140
+ // Create progress callback that reports to API
141
+ const onProgress = async (percent, message) => {
142
+ await this.pipelineApi.updateProgress(
143
+ job.id,
144
+ percent,
145
+ message,
146
+ null,
147
+ job.type,
148
+ );
149
+ };
150
+
151
+ // Configure API targets for this job
152
+ this.#configureApiTargets(job);
153
+
154
+ // Execute the appropriate command
155
+ let result;
156
+ switch (job.type) {
157
+ case 'scan':
158
+ result = await this.#processScanJob(job, onProgress);
159
+ break;
160
+ case 'identify':
161
+ result = await this.#processIdentifyJob(job, onProgress);
162
+ break;
163
+ case 'propagate':
164
+ result = await this.#processPropagateJob(job, onProgress);
165
+ break;
166
+ case 'push':
167
+ result = await this.#processPushJob(job, onProgress);
168
+ break;
169
+ case 'full':
170
+ result = await this.#processFullPipeline(job, onProgress);
171
+ break;
172
+ default:
173
+ throw new Error(`Unknown job type: ${job.type}`);
174
+ }
175
+
176
+ // Mark job as completed
177
+ await this.pipelineApi.completeJob(job.id, result);
178
+ logger.success(`āœ… Job ${job.id} completed successfully`);
179
+ } catch (error) {
180
+ logger.error(`āŒ Job ${job.id} failed: ${error.message}`);
181
+ await this.pipelineApi.failJob(job.id, error.message);
182
+ } finally {
183
+ clearInterval(heartbeatInterval);
184
+ this.currentJob = null;
185
+ // Reset API configuration
186
+ this.#resetApiConfig();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Configure API targets for cross-tenant operations
192
+ * @private
193
+ */
194
+ #configureApiTargets(job) {
195
+ if (job.sourceApi && job.targetApi) {
196
+ appConfig.setCrossTenantTargets(job.sourceApi, job.targetApi);
197
+ } else {
198
+ appConfig.setApiTarget(job.sourceApi || 'agencia');
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Reset API configuration after job
204
+ * @private
205
+ */
206
+ #resetApiConfig() {
207
+ // Clear cross-tenant mode
208
+ appConfig.api.sourceTarget = null;
209
+ appConfig.api.targetTarget = null;
210
+ }
211
+
212
+ /**
213
+ * Override scan configuration from job data
214
+ * @private
215
+ */
216
+ #overrideScanConfig(job) {
217
+ if (job.scanConfig) {
218
+ if (job.scanConfig.companySlug) {
219
+ process.env.ARELA_COMPANY_SLUG = job.scanConfig.companySlug;
220
+ }
221
+ if (job.scanConfig.serverId) {
222
+ process.env.ARELA_SERVER_ID = job.scanConfig.serverId;
223
+ }
224
+ if (job.scanConfig.basePath) {
225
+ process.env.UPLOAD_BASE_PATH = job.scanConfig.basePath;
226
+ process.env.ARELA_BASE_PATH_LABEL = job.scanConfig.basePath;
227
+ }
228
+ if (job.scanConfig.directoryLevel !== undefined) {
229
+ process.env.SCAN_DIRECTORY_LEVEL = String(
230
+ job.scanConfig.directoryLevel,
231
+ );
232
+ }
233
+ }
234
+
235
+ // Override scan directories if provided
236
+ if (job.scanDirectories && job.scanDirectories.length > 0) {
237
+ process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
238
+ }
239
+
240
+ // Override file extensions if provided
241
+ if (job.fileExtensions && job.fileExtensions.length > 0) {
242
+ process.env.UPLOAD_FILE_EXTENSIONS = job.fileExtensions.join(',');
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Process a scan job
248
+ * @private
249
+ */
250
+ async #processScanJob(job, onProgress) {
251
+ const { ScanCommand } = await import('./ScanCommand.js');
252
+ const scanCommand = new ScanCommand();
253
+
254
+ this.#overrideScanConfig(job);
255
+
256
+ const options = {
257
+ api: job.sourceApi || 'agencia',
258
+ countFirst: false,
259
+ stream: true,
260
+ onProgress,
261
+ };
262
+
263
+ return scanCommand.execute(options);
264
+ }
265
+
266
+ /**
267
+ * Process an identify job
268
+ * @private
269
+ */
270
+ async #processIdentifyJob(job, onProgress) {
271
+ const { IdentifyCommand } = await import('./IdentifyCommand.js');
272
+ const identifyCommand = new IdentifyCommand();
273
+
274
+ this.#overrideScanConfig(job);
275
+
276
+ const options = {
277
+ api: job.sourceApi || 'agencia',
278
+ batchSize: 100,
279
+ showStats: false,
280
+ onProgress,
281
+ };
282
+
283
+ return identifyCommand.execute(options);
284
+ }
285
+
286
+ /**
287
+ * Process a propagate job
288
+ * @private
289
+ */
290
+ async #processPropagateJob(job, onProgress) {
291
+ const { PropagateCommand } = await import('./PropagateCommand.js');
292
+
293
+ this.#overrideScanConfig(job);
294
+
295
+ const options = {
296
+ api: job.sourceApi || 'agencia',
297
+ batchSize: 50,
298
+ showStats: false,
299
+ onProgress,
300
+ };
301
+
302
+ const propagateCommand = new PropagateCommand(options);
303
+ return propagateCommand.execute();
304
+ }
305
+
306
+ /**
307
+ * Process a push job
308
+ * @private
309
+ */
310
+ async #processPushJob(job, onProgress) {
311
+ const { PushCommand } = await import('./PushCommand.js');
312
+ const pushCommand = new PushCommand();
313
+
314
+ this.#overrideScanConfig(job);
315
+
316
+ const options = {
317
+ scanApi: job.sourceApi || 'agencia',
318
+ pushApi: job.targetApi || 'cliente',
319
+ rfcs: [job.rfc],
320
+ batchSize: 100,
321
+ uploadBatchSize: 10,
322
+ folderStructure: job.folderStructure || 'pedimento',
323
+ autoOrganize: true,
324
+ showStats: false,
325
+ onProgress,
326
+ };
327
+
328
+ return pushCommand.execute(options);
329
+ }
330
+
331
+ /**
332
+ * Process full pipeline (scan → identify → propagate → push)
333
+ * @private
334
+ */
335
+ async #processFullPipeline(job, onProgress) {
336
+ const results = {};
337
+
338
+ // Step 1: Scan (0-25%)
339
+ logger.info('šŸ“ Step 1/4: Scanning files...');
340
+ const wrappedProgress1 = (pct, msg) =>
341
+ onProgress(pct * 0.25, `[Scan] ${msg}`);
342
+ results.scan = await this.#processScanJob(job, wrappedProgress1);
343
+
344
+ // Step 2: Identify (25-50%)
345
+ logger.info('šŸ” Step 2/4: Identifying documents...');
346
+ const wrappedProgress2 = (pct, msg) =>
347
+ onProgress(25 + pct * 0.25, `[Identify] ${msg}`);
348
+ results.identify = await this.#processIdentifyJob(job, wrappedProgress2);
349
+
350
+ // Step 3: Propagate (50-75%)
351
+ logger.info('šŸ”„ Step 3/4: Propagating paths...');
352
+ const wrappedProgress3 = (pct, msg) =>
353
+ onProgress(50 + pct * 0.25, `[Propagate] ${msg}`);
354
+ results.propagate = await this.#processPropagateJob(job, wrappedProgress3);
355
+
356
+ // Step 4: Push (75-100%)
357
+ logger.info('šŸ“¤ Step 4/4: Pushing to storage...');
358
+ const wrappedProgress4 = (pct, msg) =>
359
+ onProgress(75 + pct * 0.25, `[Push] ${msg}`);
360
+ results.push = await this.#processPushJob(job, wrappedProgress4);
361
+
362
+ return results;
363
+ }
364
+
365
+ /**
366
+ * Sleep for specified milliseconds
367
+ * @private
368
+ */
369
+ #sleep(ms) {
370
+ return new Promise((resolve) => {
371
+ this.pollTimer = setTimeout(resolve, ms);
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Setup graceful shutdown handlers
377
+ * @private
378
+ */
379
+ #setupShutdownHandlers() {
380
+ const shutdown = async (signal) => {
381
+ if (this.isShuttingDown) return;
382
+ this.isShuttingDown = true;
383
+
384
+ logger.info(`\nšŸ‘‹ Received ${signal}. Shutting down poll worker...`);
385
+
386
+ // Clear poll timer
387
+ if (this.pollTimer) {
388
+ clearTimeout(this.pollTimer);
389
+ }
390
+
391
+ // Wait for current job to finish if any
392
+ if (this.currentJob) {
393
+ logger.info(
394
+ ` ā³ Waiting for current job to finish: ${this.currentJob.id}`,
395
+ );
396
+ // Give it a moment to complete
397
+ await this.#sleep(2000);
398
+ }
399
+
400
+ // Cleanup
401
+ if (this.pipelineApi) {
402
+ this.pipelineApi.destroy();
403
+ }
404
+
405
+ logger.info('āœ… Poll worker stopped gracefully');
406
+ process.exit(0);
407
+ };
408
+
409
+ process.on('SIGINT', () => shutdown('SIGINT'));
410
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
411
+ }
412
+ }
413
+
414
+ export default new PollWorkerCommand();
@@ -16,6 +16,7 @@ export class PropagateCommand {
16
16
  batchSize: parseInt(options.batchSize) || 50, // Process 50 pedimentos at a time
17
17
  showStats: options.showStats || false,
18
18
  api: options.api || 'default',
19
+ onProgress: options.onProgress || null, // Progress callback for worker mode
19
20
  };
20
21
 
21
22
  this.scanApiService = null;
@@ -75,8 +76,19 @@ export class PropagateCommand {
75
76
  directoriesProcessed: 0,
76
77
  };
77
78
 
78
- for (const table of tables) {
79
+ // Report initial progress
80
+ this.#reportProgress(
81
+ 0,
82
+ `Starting propagation on ${tables.length} tables`,
83
+ );
84
+
85
+ for (let i = 0; i < tables.length; i++) {
86
+ const table = tables[i];
79
87
  console.log(`\nšŸ”„ Processing table: ${table.tableName}\n`);
88
+ this.#reportProgress(
89
+ Math.round((i / tables.length) * 100),
90
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
91
+ );
80
92
  this.tableName = table.tableName;
81
93
 
82
94
  // Process this table
@@ -95,6 +107,12 @@ export class PropagateCommand {
95
107
  ? (totalStats.filesUpdated / parseFloat(duration)).toFixed(1)
96
108
  : 0;
97
109
 
110
+ // Report completion
111
+ this.#reportProgress(
112
+ 100,
113
+ `Propagation completed: ${totalStats.filesUpdated} files updated`,
114
+ );
115
+
98
116
  console.log('\\nāœ… Propagation Complete!\\n');
99
117
  console.log(`šŸ“Š Total Results:`);
100
118
  console.log(` Tables Processed: ${tables.length}`);
@@ -473,6 +491,25 @@ export class PropagateCommand {
473
491
  const used = process.memoryUsage();
474
492
  return `${Math.round(used.heapUsed / 1024 / 1024)}MB`;
475
493
  }
494
+
495
+ /**
496
+ * Report progress to callback if available (for worker mode)
497
+ * @private
498
+ * @param {number} percent - Progress percentage (0-100)
499
+ * @param {string} message - Progress message
500
+ */
501
+ #reportProgress(percent, message) {
502
+ if (
503
+ this.options.onProgress &&
504
+ typeof this.options.onProgress === 'function'
505
+ ) {
506
+ try {
507
+ this.options.onProgress(percent, message);
508
+ } catch (error) {
509
+ logger.debug(`Progress callback error: ${error.message}`);
510
+ }
511
+ }
512
+ }
476
513
  }
477
514
 
478
515
  export default PropagateCommand;
@@ -17,13 +17,16 @@ export class PushCommand {
17
17
  constructor() {
18
18
  // ScanApiService will be initialized in execute() with proper API target
19
19
  this.scanApiService = null;
20
+ this.onProgress = null; // Progress callback for worker mode
20
21
  }
21
22
 
22
23
  /**
23
24
  * Execute the push command
24
25
  * @param {Object} options - Command options
26
+ * @param {Function} options.onProgress - Optional callback for progress updates (percent, message)
25
27
  */
26
28
  async execute(options) {
29
+ this.onProgress = options.onProgress || null;
27
30
  try {
28
31
  console.log('\nšŸš€ Starting arela push command\n');
29
32
 
@@ -127,8 +130,16 @@ export class PushCommand {
127
130
  startTime: Date.now(),
128
131
  };
129
132
 
130
- for (const table of tables) {
133
+ // Report initial progress
134
+ this.#reportProgress(0, `Starting push on ${tables.length} tables`);
135
+
136
+ for (let i = 0; i < tables.length; i++) {
137
+ const table = tables[i];
131
138
  console.log(`\\nšŸš€ Processing table: ${table.tableName}\\n`);
139
+ this.#reportProgress(
140
+ Math.round((i / tables.length) * 100),
141
+ `Processing table ${i + 1}/${tables.length}: ${table.tableName}`,
142
+ );
132
143
 
133
144
  // Get initial statistics for this table
134
145
  const initialStats = await this.scanApiService.getPushStats(
@@ -175,6 +186,12 @@ export class PushCommand {
175
186
  ? (totalResults.processed / parseFloat(duration)).toFixed(0)
176
187
  : 0;
177
188
 
189
+ // Report completion
190
+ this.#reportProgress(
191
+ 100,
192
+ `Push completed: ${totalResults.uploaded} files uploaded`,
193
+ );
194
+
178
195
  console.log('\\nāœ… Push Complete!\\n');
179
196
  console.log('šŸ“Š Total Results:');
180
197
  console.log(` Tables Processed: ${tables.length}`);
@@ -702,6 +719,22 @@ export class PushCommand {
702
719
  };
703
720
  }
704
721
  }
722
+
723
+ /**
724
+ * Report progress to callback if available (for worker mode)
725
+ * @private
726
+ * @param {number} percent - Progress percentage (0-100)
727
+ * @param {string} message - Progress message
728
+ */
729
+ #reportProgress(percent, message) {
730
+ if (this.onProgress && typeof this.onProgress === 'function') {
731
+ try {
732
+ this.onProgress(percent, message);
733
+ } catch (error) {
734
+ logger.debug(`Progress callback error: ${error.message}`);
735
+ }
736
+ }
737
+ }
705
738
  }
706
739
 
707
740
  export default PushCommand;