@arela/uploader 0.2.12 → 0.3.0

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 (38) hide show
  1. package/.env.template +66 -0
  2. package/.vscode/settings.json +1 -0
  3. package/README.md +134 -58
  4. package/SUPABASE_UPLOAD_FIX.md +157 -0
  5. package/package.json +3 -2
  6. package/scripts/cleanup-ds-store.js +109 -0
  7. package/scripts/cleanup-system-files.js +69 -0
  8. package/scripts/tests/phase-7-features.test.js +415 -0
  9. package/scripts/tests/signal-handling.test.js +275 -0
  10. package/scripts/tests/smart-watch-integration.test.js +554 -0
  11. package/scripts/tests/watch-service-integration.test.js +584 -0
  12. package/src/commands/UploadCommand.js +36 -2
  13. package/src/commands/WatchCommand.js +1305 -0
  14. package/src/config/config.js +113 -0
  15. package/src/document-type-shared.js +2 -0
  16. package/src/document-types/support-document.js +201 -0
  17. package/src/file-detection.js +2 -1
  18. package/src/index.js +44 -0
  19. package/src/services/AdvancedFilterService.js +505 -0
  20. package/src/services/AutoProcessingService.js +639 -0
  21. package/src/services/BenchmarkingService.js +381 -0
  22. package/src/services/DatabaseService.js +723 -170
  23. package/src/services/ErrorMonitor.js +275 -0
  24. package/src/services/LoggingService.js +419 -1
  25. package/src/services/MonitoringService.js +401 -0
  26. package/src/services/PerformanceOptimizer.js +511 -0
  27. package/src/services/ReportingService.js +511 -0
  28. package/src/services/SignalHandler.js +255 -0
  29. package/src/services/SmartWatchDatabaseService.js +527 -0
  30. package/src/services/WatchService.js +783 -0
  31. package/src/services/upload/ApiUploadService.js +30 -4
  32. package/src/services/upload/SupabaseUploadService.js +28 -6
  33. package/src/utils/CleanupManager.js +262 -0
  34. package/src/utils/FileOperations.js +41 -0
  35. package/src/utils/WatchEventHandler.js +517 -0
  36. package/supabase/migrations/001_create_initial_schema.sql +366 -0
  37. package/supabase/migrations/002_align_with_arela_api_schema.sql +145 -0
  38. package/commands.md +0 -6
@@ -0,0 +1,527 @@
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
+
4
+ import logger from './LoggingService.js';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ /**
10
+ * SmartWatchDatabaseService
11
+ * Specialized database service for Smart Watch Queue management
12
+ * Handles file state transitions, batch processing, and progress tracking
13
+ */
14
+ export class SmartWatchDatabaseService {
15
+ #databaseService;
16
+
17
+ /**
18
+ * Initialize with a DatabaseService instance
19
+ * @param {DatabaseService} databaseService - The main database service
20
+ */
21
+ constructor(databaseService) {
22
+ if (!databaseService) {
23
+ throw new Error('DatabaseService instance is required');
24
+ }
25
+ this.#databaseService = databaseService;
26
+ }
27
+
28
+ /**
29
+ * Inserta un archivo a la tabla uploader con estado PENDING o READY_TO_UPLOAD
30
+ * @param {string} filePath - Ruta del archivo
31
+ * @param {Object} options - Opciones { processingStatus, dependsOnPath, size, extension }
32
+ * @returns {Promise<Object>} Archivo insertado
33
+ */
34
+ async insertFileToUploader(filePath, options = {}) {
35
+ const {
36
+ processingStatus = 'PENDING',
37
+ dependsOnPath = null,
38
+ documentType = null,
39
+ size = null,
40
+ fileExtension = null,
41
+ } = options;
42
+
43
+ return this.#databaseService.queryWithRetry(async () => {
44
+ const supabase = await this.#databaseService.getSupabaseClient();
45
+ const fileName = path.basename(filePath);
46
+
47
+ const fileData = {
48
+ name: fileName,
49
+ original_path: filePath,
50
+ filename: fileName,
51
+ file_extension:
52
+ fileExtension ||
53
+ path.extname(fileName).toLowerCase().replace('.', ''),
54
+ size: size || 0,
55
+ document_type: documentType,
56
+ processing_status: processingStatus,
57
+ depends_on_path: dependsOnPath,
58
+ created_at: new Date().toISOString(),
59
+ updated_at: new Date().toISOString(),
60
+ upload_attempts: 0,
61
+ status: 'pending',
62
+ };
63
+
64
+ const { data, error } = await supabase
65
+ .from('uploader')
66
+ .insert([fileData])
67
+ .select();
68
+
69
+ // Handle duplicate key errors gracefully
70
+ if (error) {
71
+ if (error.message.includes('duplicate key')) {
72
+ logger.debug(`⏭️ File already exists: ${fileName}`);
73
+ // Return existing file data instead of throwing
74
+ return { file_path: filePath, file_name: fileName };
75
+ }
76
+ throw new Error(`Error inserting file: ${error.message}`);
77
+ }
78
+
79
+ logger.debug(
80
+ `✅ File inserted: ${fileName} (status: ${processingStatus})`,
81
+ );
82
+ return data?.[0];
83
+ }, 'Insert file to uploader');
84
+ }
85
+
86
+ /**
87
+ * Obtiene archivos PENDING en un directorio específico
88
+ * @param {string} dirPath - Ruta del directorio
89
+ * @returns {Promise<Array>} Archivos pendientes
90
+ */
91
+ async getPendingFilesInDirectory(dirPath) {
92
+ return this.#databaseService.queryWithRetry(async () => {
93
+ const supabase = await this.#databaseService.getSupabaseClient();
94
+
95
+ const { data, error } = await supabase
96
+ .from('uploader')
97
+ .select('*')
98
+ .eq('depends_on_path', dirPath)
99
+ .eq('processing_status', 'PENDING')
100
+ .order('created_at', { ascending: true });
101
+
102
+ if (error) {
103
+ throw new Error(`Error getting pending files: ${error.message}`);
104
+ }
105
+
106
+ return data || [];
107
+ }, 'Get pending files in directory');
108
+ }
109
+
110
+ /**
111
+ * Obtiene archivos con ciertos status
112
+ * @param {Array} statuses - Array de status a buscar
113
+ * @param {number} limit - Límite de resultados
114
+ * @returns {Promise<Array>} Archivos con los status especificados
115
+ */
116
+ async getFilesWithStatus(statuses, limit = 10) {
117
+ return this.#databaseService.queryWithRetry(async () => {
118
+ const supabase = await this.#databaseService.getSupabaseClient();
119
+
120
+ let query = supabase
121
+ .from('uploader')
122
+ .select('*')
123
+ .in('processing_status', statuses)
124
+ .order('created_at', { ascending: true })
125
+ .limit(limit);
126
+
127
+ const { data, error } = await query;
128
+
129
+ if (error) {
130
+ throw new Error(`Error getting files with status: ${error.message}`);
131
+ }
132
+
133
+ return data || [];
134
+ }, 'Get files with status');
135
+ }
136
+
137
+ /**
138
+ * Actualiza el status de un archivo
139
+ * @param {string} filePath - Ruta original del archivo
140
+ * @param {Object} updates - Actualizaciones a realizar
141
+ * @returns {Promise<Object>} Archivo actualizado
142
+ */
143
+ async updateFileStatus(filePath, updates) {
144
+ return this.#databaseService.queryWithRetry(async () => {
145
+ const supabase = await this.#databaseService.getSupabaseClient();
146
+
147
+ const updateData = {
148
+ ...updates,
149
+ updated_at: new Date().toISOString(),
150
+ };
151
+
152
+ const { data, error } = await supabase
153
+ .from('uploader')
154
+ .update(updateData)
155
+ .eq('original_path', filePath)
156
+ .select();
157
+
158
+ if (error) {
159
+ throw new Error(`Error updating file status: ${error.message}`);
160
+ }
161
+
162
+ logger.debug(
163
+ `✅ File status updated: ${path.basename(filePath)} -> ${updates.processing_status}`,
164
+ );
165
+ return data?.[0];
166
+ }, 'Update file status');
167
+ }
168
+
169
+ /**
170
+ * Marca un directorio como "tiene pedimento detectado"
171
+ * @param {string} dirPath - Ruta del directorio
172
+ * @param {string} pedimentoPath - Ruta del archivo pedimento
173
+ * @returns {Promise<Object>} Archivo pedimento actualizado
174
+ */
175
+ async markPedimentoDetected(dirPath, pedimentoPath) {
176
+ return this.#databaseService.queryWithRetry(async () => {
177
+ const supabase = await this.#databaseService.getSupabaseClient();
178
+
179
+ const { data, error } = await supabase
180
+ .from('uploader')
181
+ .update({
182
+ pedimento_detected_at: new Date().toISOString(),
183
+ processing_status: 'READY_TO_UPLOAD',
184
+ updated_at: new Date().toISOString(),
185
+ })
186
+ .eq('original_path', pedimentoPath)
187
+ .select();
188
+
189
+ if (error) {
190
+ throw new Error(`Error marking pedimento detected: ${error.message}`);
191
+ }
192
+
193
+ logger.info(
194
+ `✅ Pedimento marked as detected: ${path.basename(pedimentoPath)}`,
195
+ );
196
+
197
+ // Ahora marcar archivos pendientes como READY_TO_UPLOAD
198
+ const pendingFiles = await this.getPendingFilesInDirectory(dirPath);
199
+
200
+ if (pendingFiles.length > 0) {
201
+ logger.info(
202
+ `🔄 Marking ${pendingFiles.length} pending files as READY_TO_UPLOAD`,
203
+ );
204
+
205
+ for (const file of pendingFiles) {
206
+ await this.updateFileStatus(file.original_path, {
207
+ processing_status: 'READY_TO_UPLOAD',
208
+ pedimento_detected_at: new Date().toISOString(),
209
+ });
210
+ }
211
+ }
212
+
213
+ return data?.[0];
214
+ }, 'Mark pedimento detected');
215
+ }
216
+
217
+ /**
218
+ * Obtiene archivos que llevan esperando más del máximo
219
+ * @param {number} maxWaitTimeSeconds - Tiempo máximo en segundos
220
+ * @returns {Promise<Array>} Archivos expirados
221
+ */
222
+ async getExpiredWaitingFiles(maxWaitTimeSeconds) {
223
+ return this.#databaseService.queryWithRetry(async () => {
224
+ const supabase = await this.#databaseService.getSupabaseClient();
225
+ const cutoffTime = new Date(
226
+ Date.now() - maxWaitTimeSeconds * 1000,
227
+ ).toISOString();
228
+
229
+ const { data, error } = await supabase
230
+ .from('uploader')
231
+ .select('*')
232
+ .eq('processing_status', 'PENDING')
233
+ .lt('created_at', cutoffTime)
234
+ .order('created_at', { ascending: true });
235
+
236
+ if (error) {
237
+ throw new Error(
238
+ `Error getting expired waiting files: ${error.message}`,
239
+ );
240
+ }
241
+
242
+ return data || [];
243
+ }, 'Get expired waiting files');
244
+ }
245
+
246
+ /**
247
+ * Obtiene estadísticas de procesamiento
248
+ * @returns {Promise<Object>} Estadísticas por status
249
+ */
250
+ async getProcessingStats() {
251
+ return this.#databaseService.queryWithRetry(async () => {
252
+ const supabase = await this.#databaseService.getSupabaseClient();
253
+ const statuses = [
254
+ 'PENDING',
255
+ 'READY_TO_UPLOAD',
256
+ 'PROCESSING',
257
+ 'UPLOADED',
258
+ 'FAILED',
259
+ ];
260
+
261
+ const stats = {
262
+ pending: 0,
263
+ readyToUpload: 0,
264
+ processing: 0,
265
+ uploaded: 0,
266
+ failed: 0,
267
+ total: 0,
268
+ };
269
+
270
+ for (const status of statuses) {
271
+ const { count, error } = await supabase
272
+ .from('uploader')
273
+ .select('*', { count: 'exact', head: true })
274
+ .eq('processing_status', status);
275
+
276
+ if (!error && count !== null) {
277
+ stats.total += count;
278
+ switch (status) {
279
+ case 'PENDING':
280
+ stats.pending = count;
281
+ break;
282
+ case 'READY_TO_UPLOAD':
283
+ stats.readyToUpload = count;
284
+ break;
285
+ case 'PROCESSING':
286
+ stats.processing = count;
287
+ break;
288
+ case 'UPLOADED':
289
+ stats.uploaded = count;
290
+ break;
291
+ case 'FAILED':
292
+ stats.failed = count;
293
+ break;
294
+ }
295
+ }
296
+ }
297
+
298
+ return stats;
299
+ }, 'Get processing stats');
300
+ }
301
+
302
+ /**
303
+ * Obtiene archivos fallidos listos para reintentar
304
+ * @param {number} maxAttempts - Máximo número de intentos
305
+ * @returns {Promise<Array>} Archivos fallidos para reintentar
306
+ */
307
+ async getFailedFilesForRetry(maxAttempts = 3) {
308
+ return this.#databaseService.queryWithRetry(async () => {
309
+ const supabase = await this.#databaseService.getSupabaseClient();
310
+
311
+ const { data, error } = await supabase
312
+ .from('uploader')
313
+ .select('*')
314
+ .eq('processing_status', 'FAILED')
315
+ .lt('upload_attempts', maxAttempts)
316
+ .order('upload_attempts', { ascending: true })
317
+ .order('updated_at', { ascending: true });
318
+
319
+ if (error) {
320
+ throw new Error(
321
+ `Error getting failed files for retry: ${error.message}`,
322
+ );
323
+ }
324
+
325
+ return data || [];
326
+ }, 'Get failed files for retry');
327
+ }
328
+
329
+ /**
330
+ * Reinicia un archivo fallido
331
+ * @param {string} filePath - Ruta del archivo
332
+ * @returns {Promise<Object>} Archivo reiniciado
333
+ */
334
+ async retryFile(filePath) {
335
+ return this.#databaseService.queryWithRetry(async () => {
336
+ return this.updateFileStatus(filePath, {
337
+ processing_status: 'READY_TO_UPLOAD',
338
+ upload_attempts: 0,
339
+ last_error: null,
340
+ });
341
+ }, `Retry file: ${filePath}`);
342
+ }
343
+
344
+ /**
345
+ * Reinicia múltiples archivos fallidos
346
+ * @param {number} limit - Cantidad máxima a reintentar
347
+ * @param {number} maxAttempts - Máximo número de intentos permitidos
348
+ * @returns {Promise<Object>} Resultado del reintento
349
+ */
350
+ async retryFailedFiles(limit = 10, maxAttempts = 3) {
351
+ return this.#databaseService.queryWithRetry(async () => {
352
+ const failedFiles = await this.getFailedFilesForRetry(maxAttempts);
353
+ const toRetry = failedFiles.slice(0, limit);
354
+
355
+ const results = {
356
+ retried: 0,
357
+ skipped: 0,
358
+ files: [],
359
+ };
360
+
361
+ for (const file of toRetry) {
362
+ try {
363
+ const updated = await this.retryFile(file.original_path);
364
+ results.retried++;
365
+ results.files.push(updated);
366
+ } catch (error) {
367
+ logger.error(
368
+ `Error retrying file ${file.filename}: ${error.message}`,
369
+ );
370
+ results.skipped++;
371
+ }
372
+ }
373
+
374
+ logger.info(
375
+ `🔄 Retried ${results.retried} files, skipped ${results.skipped}`,
376
+ );
377
+ return results;
378
+ }, 'Retry failed files');
379
+ }
380
+
381
+ /**
382
+ * Obtiene progreso general del procesamiento
383
+ * @returns {Promise<Object>} Progreso con ETA
384
+ */
385
+ async getOverallProgress() {
386
+ return this.#databaseService.queryWithRetry(async () => {
387
+ const stats = await this.getProcessingStats();
388
+
389
+ const totalFiles =
390
+ stats.pending +
391
+ stats.readyToUpload +
392
+ stats.processing +
393
+ stats.uploaded +
394
+ stats.failed;
395
+ const processedFiles = stats.uploaded;
396
+ const failedFiles = stats.failed;
397
+ const pendingFiles =
398
+ stats.pending + stats.readyToUpload + stats.processing;
399
+
400
+ // Obtener archivos recientemente subidos para calcular promedio
401
+ const supabase = await this.#databaseService.getSupabaseClient();
402
+ const { data: recentUploaded } = await supabase
403
+ .from('uploader')
404
+ .select('*')
405
+ .eq('processing_status', 'UPLOADED')
406
+ .order('updated_at', { ascending: false })
407
+ .limit(100);
408
+
409
+ let avgProcessingTimeMs = 0;
410
+ if (recentUploaded && recentUploaded.length > 0) {
411
+ const totalTime = recentUploaded.reduce((sum, file) => {
412
+ if (file.created_at && file.updated_at) {
413
+ return (
414
+ sum +
415
+ (new Date(file.updated_at).getTime() -
416
+ new Date(file.created_at).getTime())
417
+ );
418
+ }
419
+ return sum;
420
+ }, 0);
421
+ avgProcessingTimeMs = totalTime / recentUploaded.length;
422
+ }
423
+
424
+ // Estimar tiempo restante
425
+ const remainingTime = pendingFiles * avgProcessingTimeMs;
426
+ const hours = Math.floor(remainingTime / (1000 * 60 * 60));
427
+ const minutes = Math.floor(
428
+ (remainingTime % (1000 * 60 * 60)) / (1000 * 60),
429
+ );
430
+ const estimatedTimeRemaining = `${hours}h ${minutes}m`;
431
+
432
+ const percentComplete =
433
+ totalFiles > 0 ? Math.round((processedFiles / totalFiles) * 100) : 0;
434
+
435
+ return {
436
+ totalFiles,
437
+ processedFiles,
438
+ failedFiles,
439
+ pendingFiles,
440
+ percentComplete,
441
+ estimatedTimeRemaining,
442
+ avgProcessingTimeMs: Math.round(avgProcessingTimeMs),
443
+ };
444
+ }, 'Get overall progress');
445
+ }
446
+
447
+ /**
448
+ * Obtiene detalles de un archivo específico
449
+ * @param {string} filePath - Ruta del archivo
450
+ * @returns {Promise<Object>} Detalles del archivo
451
+ */
452
+ async getFileDetails(filePath) {
453
+ return this.#databaseService.queryWithRetry(async () => {
454
+ const supabase = await this.#databaseService.getSupabaseClient();
455
+
456
+ const { data, error } = await supabase
457
+ .from('uploader')
458
+ .select('*')
459
+ .eq('original_path', filePath)
460
+ .single();
461
+
462
+ if (error) {
463
+ throw new Error(`File not found: ${filePath}`);
464
+ }
465
+
466
+ return {
467
+ ...data,
468
+ timeInQueue: data.created_at
469
+ ? `${Math.round((Date.now() - new Date(data.created_at).getTime()) / 1000)}s`
470
+ : null,
471
+ attempts: data.upload_attempts,
472
+ hasError: !!data.last_error,
473
+ };
474
+ }, `Get file details: ${filePath}`);
475
+ }
476
+
477
+ /**
478
+ * Busca archivos por múltiples criterios
479
+ * @param {Object} filters - Filtros de búsqueda
480
+ * @returns {Promise<Array>} Archivos que coinciden
481
+ */
482
+ async searchFiles(filters = {}) {
483
+ return this.#databaseService.queryWithRetry(async () => {
484
+ const { status, rfc, year, dirPath, numPedimento, limit = 50 } = filters;
485
+
486
+ const supabase = await this.#databaseService.getSupabaseClient();
487
+ let query = supabase.from('uploader').select('*');
488
+
489
+ if (status) {
490
+ query = query.eq('processing_status', status);
491
+ }
492
+
493
+ if (rfc) {
494
+ query = query.eq('rfc', rfc);
495
+ }
496
+
497
+ if (year) {
498
+ query = query.eq('year', year);
499
+ }
500
+
501
+ if (dirPath) {
502
+ query = query.eq('depends_on_path', dirPath);
503
+ }
504
+
505
+ if (numPedimento) {
506
+ query = query.ilike('num_pedimento', `%${numPedimento}%`);
507
+ }
508
+
509
+ query = query.order('created_at', { ascending: false }).limit(limit);
510
+
511
+ const { data, error } = await query;
512
+
513
+ if (error) {
514
+ throw new Error(`Error searching files: ${error.message}`);
515
+ }
516
+
517
+ return data || [];
518
+ }, 'Search files');
519
+ }
520
+ }
521
+
522
+ // Export singleton factory function
523
+ export function createSmartWatchDatabaseService(databaseService) {
524
+ return new SmartWatchDatabaseService(databaseService);
525
+ }
526
+
527
+ export default SmartWatchDatabaseService;