@arela/uploader 1.0.14 → 1.0.16

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.14",
3
+ "version": "1.0.16",
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,391 @@
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
+ // Initialize Pipeline API service
53
+ this.pipelineApi = new PipelineApiService(apiTarget);
54
+
55
+ console.log('\nšŸ”§ Starting Arela Poll Worker');
56
+ console.log(`šŸ“” API Target: ${apiTarget}`);
57
+ console.log(`šŸ–„ļø Server ID: ${serverId}`);
58
+ console.log(`ā±ļø Poll Interval: ${interval}ms`);
59
+
60
+ logger.info('šŸ”§ Starting Arela Poll Worker');
61
+ logger.info(`šŸ“” API Target: ${apiTarget}`);
62
+ logger.info(`šŸ–„ļø Server ID: ${serverId}`);
63
+ logger.info(`ā±ļø Poll Interval: ${interval}ms`);
64
+
65
+ // Setup graceful shutdown handlers
66
+ this.#setupShutdownHandlers();
67
+
68
+ // Start polling loop
69
+ await this.#startPolling(serverId, interval);
70
+ } catch (error) {
71
+ logger.error('āŒ Poll Worker failed to start:', error.message);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Start the polling loop
78
+ * @private
79
+ */
80
+ async #startPolling(serverId, interval) {
81
+ console.log('\nāœ… Poll Worker is running. Press Ctrl+C to stop.\n');
82
+ logger.success('Poll Worker is running');
83
+
84
+ while (!this.isShuttingDown) {
85
+ try {
86
+ // Check for next job
87
+ const job = await this.pipelineApi.getNextJob(serverId);
88
+
89
+ if (job && job.id) {
90
+ console.log(`šŸ“„ Got job: ${job.type} for RFC ${job.rfc}`);
91
+ logger.info(`šŸ“„ Got job: ${job.id} - ${job.type} for RFC ${job.rfc}`);
92
+ await this.#processJob(job);
93
+ } else {
94
+ // Only log to file, not console (too noisy)
95
+ logger.debug('šŸ“­ No jobs available, waiting...');
96
+ }
97
+ } catch (error) {
98
+ console.error(`āŒ Polling error: ${error.message}`);
99
+ logger.error(`āŒ Polling error: ${error.message}`);
100
+ }
101
+
102
+ // Wait for next poll (unless shutting down)
103
+ if (!this.isShuttingDown) {
104
+ await this.#sleep(interval);
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Process a job
111
+ * @private
112
+ */
113
+ async #processJob(job) {
114
+ this.currentJob = job;
115
+
116
+ try {
117
+ logger.info(`šŸ”„ Processing ${job.type} job ${job.id}`);
118
+
119
+ // Create progress callback that reports to API
120
+ const onProgress = async (percent, message) => {
121
+ await this.pipelineApi.updateProgress(
122
+ job.id,
123
+ percent,
124
+ message,
125
+ null,
126
+ job.type,
127
+ );
128
+ };
129
+
130
+ // Configure API targets for this job
131
+ this.#configureApiTargets(job);
132
+
133
+ // Execute the appropriate command
134
+ let result;
135
+ switch (job.type) {
136
+ case 'scan':
137
+ result = await this.#processScanJob(job, onProgress);
138
+ break;
139
+ case 'identify':
140
+ result = await this.#processIdentifyJob(job, onProgress);
141
+ break;
142
+ case 'propagate':
143
+ result = await this.#processPropagateJob(job, onProgress);
144
+ break;
145
+ case 'push':
146
+ result = await this.#processPushJob(job, onProgress);
147
+ break;
148
+ case 'full':
149
+ result = await this.#processFullPipeline(job, onProgress);
150
+ break;
151
+ default:
152
+ throw new Error(`Unknown job type: ${job.type}`);
153
+ }
154
+
155
+ // Mark job as completed
156
+ await this.pipelineApi.completeJob(job.id, result);
157
+ logger.success(`āœ… Job ${job.id} completed successfully`);
158
+ } catch (error) {
159
+ logger.error(`āŒ Job ${job.id} failed: ${error.message}`);
160
+ await this.pipelineApi.failJob(job.id, error.message);
161
+ } finally {
162
+ this.currentJob = null;
163
+ // Reset API configuration
164
+ this.#resetApiConfig();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Configure API targets for cross-tenant operations
170
+ * @private
171
+ */
172
+ #configureApiTargets(job) {
173
+ if (job.sourceApi && job.targetApi) {
174
+ appConfig.setCrossTenantTargets(job.sourceApi, job.targetApi);
175
+ } else {
176
+ appConfig.setApiTarget(job.sourceApi || 'agencia');
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Reset API configuration after job
182
+ * @private
183
+ */
184
+ #resetApiConfig() {
185
+ // Clear cross-tenant mode
186
+ appConfig.api.sourceTarget = null;
187
+ appConfig.api.targetTarget = null;
188
+ }
189
+
190
+ /**
191
+ * Override scan configuration from job data
192
+ * @private
193
+ */
194
+ #overrideScanConfig(job) {
195
+ if (job.scanConfig) {
196
+ if (job.scanConfig.companySlug) {
197
+ process.env.ARELA_COMPANY_SLUG = job.scanConfig.companySlug;
198
+ }
199
+ if (job.scanConfig.serverId) {
200
+ process.env.ARELA_SERVER_ID = job.scanConfig.serverId;
201
+ }
202
+ if (job.scanConfig.basePath) {
203
+ process.env.UPLOAD_BASE_PATH = job.scanConfig.basePath;
204
+ process.env.ARELA_BASE_PATH_LABEL = job.scanConfig.basePath;
205
+ }
206
+ if (job.scanConfig.directoryLevel !== undefined) {
207
+ process.env.SCAN_DIRECTORY_LEVEL = String(
208
+ job.scanConfig.directoryLevel,
209
+ );
210
+ }
211
+ }
212
+
213
+ // Override scan directories if provided
214
+ if (job.scanDirectories && job.scanDirectories.length > 0) {
215
+ process.env.UPLOAD_SOURCES = job.scanDirectories.join('|');
216
+ }
217
+
218
+ // Override file extensions if provided
219
+ if (job.fileExtensions && job.fileExtensions.length > 0) {
220
+ process.env.UPLOAD_FILE_EXTENSIONS = job.fileExtensions.join(',');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Process a scan job
226
+ * @private
227
+ */
228
+ async #processScanJob(job, onProgress) {
229
+ const { ScanCommand } = await import('./ScanCommand.js');
230
+ const scanCommand = new ScanCommand();
231
+
232
+ this.#overrideScanConfig(job);
233
+
234
+ const options = {
235
+ api: job.sourceApi || 'agencia',
236
+ countFirst: false,
237
+ stream: true,
238
+ onProgress,
239
+ };
240
+
241
+ return scanCommand.execute(options);
242
+ }
243
+
244
+ /**
245
+ * Process an identify job
246
+ * @private
247
+ */
248
+ async #processIdentifyJob(job, onProgress) {
249
+ const { IdentifyCommand } = await import('./IdentifyCommand.js');
250
+ const identifyCommand = new IdentifyCommand();
251
+
252
+ this.#overrideScanConfig(job);
253
+
254
+ const options = {
255
+ api: job.sourceApi || 'agencia',
256
+ batchSize: 100,
257
+ showStats: false,
258
+ onProgress,
259
+ };
260
+
261
+ return identifyCommand.execute(options);
262
+ }
263
+
264
+ /**
265
+ * Process a propagate job
266
+ * @private
267
+ */
268
+ async #processPropagateJob(job, onProgress) {
269
+ const { PropagateCommand } = await import('./PropagateCommand.js');
270
+
271
+ this.#overrideScanConfig(job);
272
+
273
+ const options = {
274
+ api: job.sourceApi || 'agencia',
275
+ batchSize: 50,
276
+ showStats: false,
277
+ onProgress,
278
+ };
279
+
280
+ const propagateCommand = new PropagateCommand(options);
281
+ return propagateCommand.execute();
282
+ }
283
+
284
+ /**
285
+ * Process a push job
286
+ * @private
287
+ */
288
+ async #processPushJob(job, onProgress) {
289
+ const { PushCommand } = await import('./PushCommand.js');
290
+ const pushCommand = new PushCommand();
291
+
292
+ this.#overrideScanConfig(job);
293
+
294
+ const options = {
295
+ scanApi: job.sourceApi || 'agencia',
296
+ pushApi: job.targetApi || 'cliente',
297
+ batchSize: 100,
298
+ uploadBatchSize: 10,
299
+ folderStructure: job.folderStructure || 'pedimento',
300
+ autoOrganize: true,
301
+ showStats: false,
302
+ onProgress,
303
+ };
304
+
305
+ return pushCommand.execute(options);
306
+ }
307
+
308
+ /**
309
+ * Process full pipeline (scan → identify → propagate → push)
310
+ * @private
311
+ */
312
+ async #processFullPipeline(job, onProgress) {
313
+ const results = {};
314
+
315
+ // Step 1: Scan (0-25%)
316
+ logger.info('šŸ“ Step 1/4: Scanning files...');
317
+ const wrappedProgress1 = (pct, msg) =>
318
+ onProgress(pct * 0.25, `[Scan] ${msg}`);
319
+ results.scan = await this.#processScanJob(job, wrappedProgress1);
320
+
321
+ // Step 2: Identify (25-50%)
322
+ logger.info('šŸ” Step 2/4: Identifying documents...');
323
+ const wrappedProgress2 = (pct, msg) =>
324
+ onProgress(25 + pct * 0.25, `[Identify] ${msg}`);
325
+ results.identify = await this.#processIdentifyJob(job, wrappedProgress2);
326
+
327
+ // Step 3: Propagate (50-75%)
328
+ logger.info('šŸ”„ Step 3/4: Propagating paths...');
329
+ const wrappedProgress3 = (pct, msg) =>
330
+ onProgress(50 + pct * 0.25, `[Propagate] ${msg}`);
331
+ results.propagate = await this.#processPropagateJob(job, wrappedProgress3);
332
+
333
+ // Step 4: Push (75-100%)
334
+ logger.info('šŸ“¤ Step 4/4: Pushing to storage...');
335
+ const wrappedProgress4 = (pct, msg) =>
336
+ onProgress(75 + pct * 0.25, `[Push] ${msg}`);
337
+ results.push = await this.#processPushJob(job, wrappedProgress4);
338
+
339
+ return results;
340
+ }
341
+
342
+ /**
343
+ * Sleep for specified milliseconds
344
+ * @private
345
+ */
346
+ #sleep(ms) {
347
+ return new Promise((resolve) => {
348
+ this.pollTimer = setTimeout(resolve, ms);
349
+ });
350
+ }
351
+
352
+ /**
353
+ * Setup graceful shutdown handlers
354
+ * @private
355
+ */
356
+ #setupShutdownHandlers() {
357
+ const shutdown = async (signal) => {
358
+ if (this.isShuttingDown) return;
359
+ this.isShuttingDown = true;
360
+
361
+ logger.info(`\nšŸ‘‹ Received ${signal}. Shutting down poll worker...`);
362
+
363
+ // Clear poll timer
364
+ if (this.pollTimer) {
365
+ clearTimeout(this.pollTimer);
366
+ }
367
+
368
+ // Wait for current job to finish if any
369
+ if (this.currentJob) {
370
+ logger.info(
371
+ ` ā³ Waiting for current job to finish: ${this.currentJob.id}`,
372
+ );
373
+ // Give it a moment to complete
374
+ await this.#sleep(2000);
375
+ }
376
+
377
+ // Cleanup
378
+ if (this.pipelineApi) {
379
+ this.pipelineApi.destroy();
380
+ }
381
+
382
+ logger.info('āœ… Poll worker stopped gracefully');
383
+ process.exit(0);
384
+ };
385
+
386
+ process.on('SIGINT', () => shutdown('SIGINT'));
387
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
388
+ }
389
+ }
390
+
391
+ 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}`);
@@ -283,6 +300,11 @@ export class PushCommand {
283
300
  // After each batch upload, those files are no longer "pending", so the next query
284
301
  // at offset=0 will naturally return the next batch of unprocessed files
285
302
 
303
+ // Track seen file IDs to detect infinite loops (scan table update failures)
304
+ const seenFileIds = new Set();
305
+ let consecutiveRepeats = 0;
306
+ const MAX_CONSECUTIVE_REPEATS = 3;
307
+
286
308
  // Start progress bar with known total
287
309
  progressBar.start(totalToProcess, 0, {
288
310
  speed: 0,
@@ -303,6 +325,23 @@ export class PushCommand {
303
325
  break;
304
326
  }
305
327
 
328
+ // Infinite loop protection: if the same files keep coming back,
329
+ // the scan table update is failing and they stay "pending" forever.
330
+ const allSeen = files.every((f) => seenFileIds.has(f.id));
331
+ if (allSeen) {
332
+ consecutiveRepeats++;
333
+ if (consecutiveRepeats >= MAX_CONSECUTIVE_REPEATS) {
334
+ const msg = `Aborting: same ${files.length} files returned ${MAX_CONSECUTIVE_REPEATS} times — scan table updates are likely failing.`;
335
+ logger.error(msg);
336
+ console.error(`\n⚠ ${msg}`);
337
+ hasMore = false;
338
+ break;
339
+ }
340
+ } else {
341
+ consecutiveRepeats = 0;
342
+ }
343
+ files.forEach((f) => seenFileIds.add(f.id));
344
+
306
345
  // Upload files in smaller batches using new CLI upload endpoint
307
346
  for (let i = 0; i < files.length; i += uploadBatchSize) {
308
347
  const uploadBatch = files.slice(i, i + uploadBatchSize);
@@ -328,9 +367,9 @@ export class PushCommand {
328
367
  try {
329
368
  await this.scanApiService.batchUpdateUpload(tableName, batchResults);
330
369
  } catch (updateError) {
331
- logger.error(
332
- `Failed to update scan table for batch: ${updateError.message}`,
333
- );
370
+ const msg = `Failed to update scan table for batch: ${updateError.message}`;
371
+ logger.error(msg);
372
+ console.error(`\n⚠ ${msg}`);
334
373
  // Don't fail the entire process, just log the error
335
374
  }
336
375
 
@@ -482,7 +521,13 @@ export class PushCommand {
482
521
  if (apiResult.uploaded && apiResult.uploaded.length > 0) {
483
522
  const uploadedFile = apiResult.uploaded[0];
484
523
  result.uploaded = true;
485
- result.uploadedToStorageId = uploadedFile.storageId;
524
+ // Only assign storageId if it is a valid UUID; ignore placeholder values
525
+ const UUID_RE =
526
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
527
+ result.uploadedToStorageId =
528
+ uploadedFile.storageId && UUID_RE.test(uploadedFile.storageId)
529
+ ? uploadedFile.storageId
530
+ : null;
486
531
  logger.info(`āœ“ Uploaded: ${file.file_name} → ${uploadPath}`);
487
532
  } else if (apiResult.errors && apiResult.errors.length > 0) {
488
533
  const error = apiResult.errors[0];
@@ -674,6 +719,22 @@ export class PushCommand {
674
719
  };
675
720
  }
676
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
+ }
677
738
  }
678
739
 
679
740
  export default PushCommand;