@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,645 @@
1
+ import { Agent } from 'http';
2
+ import { Agent as HttpsAgent } from 'https';
3
+ import fetch from 'node-fetch';
4
+
5
+ import appConfig from '../config/config.js';
6
+ import logger from './LoggingService.js';
7
+
8
+ /**
9
+ * Scan API Service
10
+ * Handles API communication for the arela scan command
11
+ */
12
+ export class ScanApiService {
13
+ constructor() {
14
+ const apiConfig = appConfig.getApiConfig();
15
+ this.baseUrl = apiConfig.baseUrl;
16
+ this.token = apiConfig.token;
17
+
18
+ // Get API connection settings
19
+ const maxApiConnections = parseInt(process.env.MAX_API_CONNECTIONS) || 10;
20
+ const connectionTimeout =
21
+ parseInt(process.env.API_CONNECTION_TIMEOUT) || 60000;
22
+
23
+ // Get retry configuration
24
+ this.maxRetries = parseInt(process.env.API_MAX_RETRIES) || 3;
25
+ this.useExponentialBackoff =
26
+ process.env.API_RETRY_EXPONENTIAL_BACKOFF !== 'false'; // Default true
27
+ this.fixedRetryDelay = parseInt(process.env.API_RETRY_DELAY) || 1000;
28
+
29
+ // Initialize HTTP agents for connection pooling
30
+ this.httpAgent = new Agent({
31
+ keepAlive: true,
32
+ keepAliveMsecs: 30000,
33
+ maxSockets: maxApiConnections,
34
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
35
+ maxTotalSockets: maxApiConnections + 5,
36
+ timeout: connectionTimeout,
37
+ scheduling: 'fifo',
38
+ });
39
+
40
+ this.httpsAgent = new HttpsAgent({
41
+ keepAlive: true,
42
+ keepAliveMsecs: 30000,
43
+ maxSockets: maxApiConnections,
44
+ maxFreeSockets: Math.ceil(maxApiConnections / 2),
45
+ maxTotalSockets: maxApiConnections + 5,
46
+ timeout: connectionTimeout,
47
+ scheduling: 'fifo',
48
+ });
49
+
50
+ logger.debug(
51
+ `🔗 Scan API Service configured with ${maxApiConnections} concurrent connections`,
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Get the appropriate HTTP agent based on URL protocol
57
+ * @private
58
+ */
59
+ #getAgent(url) {
60
+ return url.startsWith('https://') ? this.httpsAgent : this.httpAgent;
61
+ }
62
+
63
+ /**
64
+ * Check if error is retryable
65
+ * @private
66
+ * @param {Error} error - Error to check
67
+ * @param {Response} response - HTTP response (if available)
68
+ * @returns {boolean} True if error is retryable
69
+ */
70
+ #isRetryableError(error, response = null) {
71
+ // Network errors are retryable
72
+ if (
73
+ error.code === 'ECONNRESET' ||
74
+ error.code === 'ETIMEDOUT' ||
75
+ error.code === 'ECONNREFUSED' ||
76
+ error.code === 'ENOTFOUND' ||
77
+ error.code === 'EAI_AGAIN'
78
+ ) {
79
+ return true;
80
+ }
81
+
82
+ // HTTP status codes that are retryable
83
+ if (response) {
84
+ const status = response.status;
85
+ // 429 Too Many Requests - should retry with backoff
86
+ // 5xx Server errors - temporary issues
87
+ if (status === 429 || (status >= 500 && status < 600)) {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ // Timeout errors
93
+ if (error.message && error.message.includes('timeout')) {
94
+ return true;
95
+ }
96
+
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * Calculate backoff delay
102
+ * @private
103
+ * @param {number} attempt - Current attempt number (1-based)
104
+ * @returns {number} Delay in milliseconds
105
+ */
106
+ #calculateBackoff(attempt) {
107
+ if (!this.useExponentialBackoff) {
108
+ // Fixed delay with jitter
109
+ const jitter = this.fixedRetryDelay * 0.2 * (Math.random() * 2 - 1);
110
+ return Math.floor(this.fixedRetryDelay + jitter);
111
+ }
112
+
113
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s
114
+ const baseDelay = 1000;
115
+ const maxDelay = 16000;
116
+ const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
117
+
118
+ // Add jitter (±20%) to prevent thundering herd
119
+ const jitter = delay * 0.2 * (Math.random() * 2 - 1);
120
+ return Math.floor(delay + jitter);
121
+ }
122
+
123
+ /**
124
+ * Sleep for specified milliseconds
125
+ * @private
126
+ * @param {number} ms - Milliseconds to sleep
127
+ * @returns {Promise<void>}
128
+ */
129
+ async #sleep(ms) {
130
+ return new Promise((resolve) => setTimeout(resolve, ms));
131
+ }
132
+
133
+ /**
134
+ * Make API request with retry logic and exponential backoff
135
+ * @private
136
+ * @param {string} endpoint - API endpoint
137
+ * @param {string} method - HTTP method
138
+ * @param {Object} body - Request body
139
+ * @param {Object} headers - Additional headers
140
+ * @param {number} maxRetries - Maximum retry attempts (defaults to configured value)
141
+ * @returns {Promise<Object>} Response data
142
+ */
143
+ async #request(
144
+ endpoint,
145
+ method = 'GET',
146
+ body = null,
147
+ headers = {},
148
+ maxRetries = null,
149
+ ) {
150
+ // Use configured maxRetries if not specified
151
+ const retries = maxRetries !== null ? maxRetries : this.maxRetries;
152
+
153
+ const url = `${this.baseUrl}${endpoint}`;
154
+
155
+ const options = {
156
+ method,
157
+ headers: {
158
+ 'x-api-key': this.token,
159
+ 'Content-Type': 'application/json',
160
+ ...headers,
161
+ },
162
+ agent: this.#getAgent(url),
163
+ };
164
+
165
+ if (body) {
166
+ options.body = JSON.stringify(body);
167
+ }
168
+
169
+ let lastError;
170
+ let lastResponse = null;
171
+
172
+ for (let attempt = 1; attempt <= retries + 1; attempt++) {
173
+ try {
174
+ const response = await fetch(url, options);
175
+ lastResponse = response;
176
+
177
+ if (!response.ok) {
178
+ const errorText = await response.text();
179
+ let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
180
+
181
+ try {
182
+ const errorJson = JSON.parse(errorText);
183
+ errorMessage = errorJson.message || errorMessage;
184
+ } catch {
185
+ errorMessage = errorText || errorMessage;
186
+ }
187
+
188
+ const error = new Error(errorMessage);
189
+ error.status = response.status;
190
+
191
+ // Check if error is retryable
192
+ if (this.#isRetryableError(error, response)) {
193
+ if (attempt <= retries) {
194
+ const backoffDelay = this.#calculateBackoff(attempt);
195
+ logger.warn(
196
+ `API request failed (attempt ${attempt}/${retries + 1}): ${errorMessage}. Retrying in ${backoffDelay}ms...`,
197
+ );
198
+ await this.#sleep(backoffDelay);
199
+ continue;
200
+ }
201
+ }
202
+
203
+ throw error;
204
+ }
205
+
206
+ // Success - log retry success if this wasn't the first attempt
207
+ if (attempt > 1) {
208
+ logger.info(
209
+ `API request succeeded on attempt ${attempt}/${retries + 1}`,
210
+ );
211
+ }
212
+
213
+ return await response.json();
214
+ } catch (error) {
215
+ lastError = error;
216
+
217
+ // Check if this is a retryable error
218
+ if (this.#isRetryableError(error, lastResponse)) {
219
+ if (attempt <= retries) {
220
+ const backoffDelay = this.#calculateBackoff(attempt);
221
+ logger.warn(
222
+ `API request failed (attempt ${attempt}/${retries + 1}): ${error.message}. Retrying in ${backoffDelay}ms...`,
223
+ );
224
+ await this.#sleep(backoffDelay);
225
+ continue;
226
+ }
227
+ }
228
+
229
+ // Non-retryable error or max retries reached
230
+ logger.error(
231
+ `API request failed after ${attempt} attempt(s): ${error.message}`,
232
+ );
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ // Should not reach here, but just in case
238
+ throw lastError;
239
+ }
240
+
241
+ /**
242
+ * Register a scan instance with the API
243
+ * @param {Object} config - Instance configuration
244
+ * @returns {Promise<Object>} Registration result
245
+ */
246
+ async registerInstance(config) {
247
+ logger.debug('Registering scan instance...');
248
+
249
+ const result = await this.#request('/api/uploader/scan/register', 'POST', {
250
+ companySlug: config.companySlug,
251
+ serverId: config.serverId,
252
+ basePathFull: config.basePathFull,
253
+ });
254
+
255
+ logger.debug(`Instance registered: ${result.tableName}`);
256
+ return result;
257
+ }
258
+
259
+ /**
260
+ * Bulk insert file stats
261
+ * @param {string} tableName - Target table name
262
+ * @param {Array} records - File stat records
263
+ * @returns {Promise<Object>} Insert result
264
+ */
265
+ async batchInsertStats(tableName, records) {
266
+ if (!records || records.length === 0) {
267
+ return { inserted: 0 };
268
+ }
269
+
270
+ logger.debug(`Uploading batch of ${records.length} records...`);
271
+
272
+ const result = await this.#request(
273
+ '/api/uploader/scan/batch-insert',
274
+ 'POST',
275
+ records,
276
+ {
277
+ 'x-table-name': tableName,
278
+ },
279
+ );
280
+
281
+ logger.debug(`Batch uploaded: ${result.inserted} inserted`);
282
+ return result;
283
+ }
284
+
285
+ /**
286
+ * Complete a scan and update statistics
287
+ * @param {Object} data - Completion data
288
+ * @returns {Promise<Object>} Completion result
289
+ */
290
+ async completeScan(data) {
291
+ logger.debug('Completing scan...');
292
+
293
+ const result = await this.#request('/api/uploader/scan/complete', 'PATCH', {
294
+ tableName: data.tableName,
295
+ totalFiles: data.totalFiles,
296
+ totalSizeBytes: data.totalSizeBytes,
297
+ });
298
+
299
+ logger.debug('Scan completed');
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Get all scan instances
305
+ * @returns {Promise<Array>} List of scan instances
306
+ */
307
+ async getAllInstances() {
308
+ logger.debug('Fetching scan instances...');
309
+ return await this.#request('/api/uploader/scan/instances', 'GET');
310
+ }
311
+
312
+ /**
313
+ * Get stale scan instances
314
+ * @param {number} days - Days threshold
315
+ * @returns {Promise<Array>} List of stale instances
316
+ */
317
+ async getStaleInstances(days = 90) {
318
+ logger.debug(`Fetching stale instances (${days} days)...`);
319
+ return await this.#request(
320
+ `/api/uploader/scan/stale-instances?days=${days}`,
321
+ 'GET',
322
+ );
323
+ }
324
+
325
+ /**
326
+ * Get all tables for a specific instance
327
+ * @param {string} companySlug - Company slug
328
+ * @param {string} serverId - Server ID
329
+ * @param {string} basePathFull - Base path (absolute)
330
+ * @returns {Promise<Array>} List of tables for the instance
331
+ */
332
+ async getInstanceTables(companySlug, serverId, basePathFull) {
333
+ logger.debug(
334
+ `Fetching instance tables for ${companySlug}/${serverId}/${basePathFull}...`,
335
+ );
336
+ return await this.#request(
337
+ `/api/uploader/scan/instance-tables?companySlug=${encodeURIComponent(companySlug)}&serverId=${encodeURIComponent(serverId)}&basePathFull=${encodeURIComponent(basePathFull)}`,
338
+ 'GET',
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Deactivate a scan instance
344
+ * @param {string} tableName - Table name to deactivate
345
+ * @returns {Promise<Object>} Deactivation result
346
+ */
347
+ async deactivateInstance(tableName) {
348
+ logger.debug(`Deactivating instance: ${tableName}`);
349
+
350
+ const result = await this.#request(
351
+ '/api/uploader/scan/deactivate',
352
+ 'PATCH',
353
+ {
354
+ tableName,
355
+ },
356
+ );
357
+
358
+ logger.debug('Instance deactivated');
359
+ return result;
360
+ }
361
+
362
+ // ============================================================================
363
+ // DETECTION OPERATIONS (for arela identify command)
364
+ // ============================================================================
365
+
366
+ /**
367
+ * Fetch PDF files for detection
368
+ * @param {string} tableName - Target table name
369
+ * @param {number} offset - Pagination offset
370
+ * @param {number} limit - Number of records to fetch
371
+ * @returns {Promise<Object>} { data: Array, hasMore: boolean }
372
+ */
373
+ async fetchPdfsForDetection(tableName, offset = 0, limit = 100) {
374
+ logger.debug(
375
+ `Fetching PDFs for detection (offset: ${offset}, limit: ${limit})...`,
376
+ );
377
+
378
+ const result = await this.#request(
379
+ `/api/uploader/scan/pdfs-for-detection?tableName=${encodeURIComponent(tableName)}&offset=${offset}&limit=${limit}`,
380
+ 'GET',
381
+ );
382
+
383
+ logger.debug(
384
+ `Fetched ${result.data.length} PDFs, hasMore: ${result.hasMore}`,
385
+ );
386
+ return result;
387
+ }
388
+
389
+ /**
390
+ * Batch update detection results
391
+ * @param {string} tableName - Target table name
392
+ * @param {Array} updates - Detection results
393
+ * @returns {Promise<Object>} { updated: number, errors: number }
394
+ */
395
+ async batchUpdateDetection(tableName, updates) {
396
+ if (!updates || updates.length === 0) {
397
+ return { updated: 0, errors: 0 };
398
+ }
399
+
400
+ logger.debug(`Updating detection results for ${updates.length} files...`);
401
+
402
+ const result = await this.#request(
403
+ `/api/uploader/scan/batch-update-detection?tableName=${encodeURIComponent(tableName)}`,
404
+ 'PATCH',
405
+ updates,
406
+ );
407
+
408
+ logger.debug(
409
+ `Detection updated: ${result.updated} successful, ${result.errors} errors`,
410
+ );
411
+ return result;
412
+ }
413
+
414
+ /**
415
+ * Get detection statistics
416
+ * @param {string} tableName - Target table name
417
+ * @returns {Promise<Object>} { totalPdfs, detected, pending, errors }
418
+ */
419
+ async getDetectionStats(tableName) {
420
+ logger.debug('Fetching detection statistics...');
421
+
422
+ const result = await this.#request(
423
+ `/api/uploader/scan/detection-stats?tableName=${encodeURIComponent(tableName)}`,
424
+ 'GET',
425
+ );
426
+
427
+ logger.debug(
428
+ `Detection stats: ${result.detected}/${result.totalPdfs} detected, ${result.pending} pending`,
429
+ );
430
+ return result;
431
+ }
432
+
433
+ // ============================================================================
434
+ // PROPAGATION API METHODS (for arela propagate command)
435
+ // ============================================================================
436
+
437
+ /**
438
+ * Mark files needing propagation
439
+ * @param {string} tableName - Target table name
440
+ * @returns {Promise<Object>} { markedCount: number }
441
+ */
442
+ async markFilesNeedingPropagation(tableName) {
443
+ logger.debug('Marking files needing propagation...');
444
+
445
+ const result = await this.#request(
446
+ `/api/uploader/scan/mark-propagation?tableName=${encodeURIComponent(tableName)}`,
447
+ 'POST',
448
+ );
449
+
450
+ logger.debug(`Marked ${result.markedCount} files for propagation`);
451
+ return result;
452
+ }
453
+
454
+ /**
455
+ * Fetch pedimento sources for propagation
456
+ * @param {string} tableName - Target table name
457
+ * @param {number} offset - Pagination offset
458
+ * @param {number} limit - Number of records to fetch
459
+ * @returns {Promise<Array>} Array of pedimento sources
460
+ */
461
+ async fetchPedimentoSources(tableName, offset = 0, limit = 100) {
462
+ logger.debug(
463
+ `Fetching pedimento sources (offset: ${offset}, limit: ${limit})...`,
464
+ );
465
+
466
+ const result = await this.#request(
467
+ `/api/uploader/scan/pedimento-sources?tableName=${encodeURIComponent(tableName)}&offset=${offset}&limit=${limit}`,
468
+ 'GET',
469
+ );
470
+
471
+ // Validate response is an array
472
+ if (!Array.isArray(result)) {
473
+ logger.error(
474
+ 'fetchPedimentoSources: Expected array, got:',
475
+ typeof result,
476
+ );
477
+ logger.error('Response data:', JSON.stringify(result).substring(0, 200));
478
+ return [];
479
+ }
480
+
481
+ logger.debug(`Fetched ${result.length} pedimento sources`);
482
+ return result;
483
+ }
484
+
485
+ /**
486
+ * Fetch files needing propagation by directory
487
+ * @param {string} tableName - Target table name
488
+ * @param {string} directoryPath - Directory path to query
489
+ * @returns {Promise<Array>} Array of files needing propagation
490
+ */
491
+ async fetchFilesNeedingPropagationByDirectory(tableName, directoryPath) {
492
+ const result = await this.#request(
493
+ `/api/uploader/scan/files-by-directory?tableName=${encodeURIComponent(tableName)}&directoryPath=${encodeURIComponent(directoryPath)}`,
494
+ 'GET',
495
+ );
496
+
497
+ // Validate response is an array
498
+ if (!Array.isArray(result)) {
499
+ logger.error(
500
+ 'fetchFilesNeedingPropagationByDirectory: Expected array, got:',
501
+ typeof result,
502
+ );
503
+ return [];
504
+ }
505
+
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * Batch update propagation results
511
+ * @param {string} tableName - Target table name
512
+ * @param {Array} updates - Propagation results
513
+ * @returns {Promise<Object>} { updated: number, errors: number }
514
+ */
515
+ async batchUpdatePropagation(tableName, updates) {
516
+ if (!updates || updates.length === 0) {
517
+ return { updated: 0, errors: 0 };
518
+ }
519
+
520
+ logger.debug(`Updating propagation results for ${updates.length} files...`);
521
+
522
+ const result = await this.#request(
523
+ `/api/uploader/scan/batch-update-propagation?tableName=${encodeURIComponent(tableName)}`,
524
+ 'PATCH',
525
+ { updates },
526
+ );
527
+
528
+ logger.debug(
529
+ `Propagation updated: ${result.updated} successful, ${result.errors} errors`,
530
+ );
531
+ return result;
532
+ }
533
+
534
+ /**
535
+ * Get propagation statistics
536
+ * @param {string} tableName - Target table name
537
+ * @returns {Promise<Object>} { totalFiles, withArelaPath, needsPropagation, pending, errors, maxAttemptsReached, pedimentoSources }
538
+ */
539
+ async getPropagationStats(tableName) {
540
+ logger.debug('Fetching propagation statistics...');
541
+
542
+ const result = await this.#request(
543
+ `/api/uploader/scan/propagation-stats?tableName=${encodeURIComponent(tableName)}`,
544
+ 'GET',
545
+ );
546
+
547
+ logger.debug(
548
+ `Propagation stats: ${result.withArelaPath}/${result.totalFiles} with arela_path, ${result.pending} pending`,
549
+ );
550
+ return result;
551
+ }
552
+
553
+ // ============================================================================
554
+ // PUSH OPERATIONS
555
+ // ============================================================================
556
+
557
+ /**
558
+ * Fetch files ready for upload (push command)
559
+ * @param {string} tableName - Target table name
560
+ * @param {Object} options - Query options
561
+ * @param {string[]} options.rfcs - RFCs to filter by
562
+ * @param {number[]} options.years - Years to filter by
563
+ * @param {number} options.offset - Pagination offset
564
+ * @param {number} options.limit - Pagination limit
565
+ * @returns {Promise<Array>} Array of files ready for upload
566
+ */
567
+ async fetchFilesForPush(tableName, options = {}) {
568
+ const { rfcs, years, offset = 0, limit = 100 } = options;
569
+
570
+ // Build query string
571
+ const params = new URLSearchParams({
572
+ tableName,
573
+ offset: offset.toString(),
574
+ limit: limit.toString(),
575
+ });
576
+
577
+ if (rfcs && rfcs.length > 0) {
578
+ params.append('rfcs', rfcs.join(','));
579
+ }
580
+
581
+ if (years && years.length > 0) {
582
+ params.append('years', years.join(','));
583
+ }
584
+
585
+ const result = await this.#request(
586
+ `/api/uploader/scan/files-for-push?${params.toString()}`,
587
+ 'GET',
588
+ );
589
+
590
+ // Validate response is an array
591
+ if (!Array.isArray(result)) {
592
+ logger.error('fetchFilesForPush: Expected array, got:', typeof result);
593
+ return [];
594
+ }
595
+
596
+ logger.debug(`Fetched ${result.length} files for push`);
597
+ return result;
598
+ }
599
+
600
+ /**
601
+ * Batch update upload results
602
+ * @param {string} tableName - Target table name
603
+ * @param {Array} updates - Upload results
604
+ * @returns {Promise<Object>} { updated: number, errors: number }
605
+ */
606
+ async batchUpdateUpload(tableName, updates) {
607
+ if (!updates || updates.length === 0) {
608
+ return { updated: 0, errors: 0 };
609
+ }
610
+
611
+ logger.debug(`Updating upload results for ${updates.length} files...`);
612
+
613
+ const result = await this.#request(
614
+ `/api/uploader/scan/batch-update-upload?tableName=${encodeURIComponent(tableName)}`,
615
+ 'PATCH',
616
+ { updates },
617
+ );
618
+
619
+ logger.debug(
620
+ `Upload updated: ${result.updated} successful, ${result.errors} errors`,
621
+ );
622
+ return result;
623
+ }
624
+
625
+ /**
626
+ * Get push statistics
627
+ * @param {string} tableName - Target table name
628
+ * @returns {Promise<Object>} { totalWithArelaPath, uploaded, pending, errors, maxAttemptsReached, byRfc }
629
+ */
630
+ async getPushStats(tableName) {
631
+ logger.debug('Fetching push statistics...');
632
+
633
+ const result = await this.#request(
634
+ `/api/uploader/scan/push-stats?tableName=${encodeURIComponent(tableName)}`,
635
+ 'GET',
636
+ );
637
+
638
+ logger.debug(
639
+ `Push stats: ${result.uploaded}/${result.totalWithArelaPath} uploaded, ${result.pending} pending`,
640
+ );
641
+ return result;
642
+ }
643
+ }
644
+
645
+ export default ScanApiService;