/** * Sync Queue - Operation queue for pending mutations * * Handles queueing operations when offline and processing them when online. * Implements idempotency keys, retry logic with exponential backoff, and priority ordering. */ import { db, OperationType, OperationPriority, SyncStatus } from './db.js'; import { getDeviceId } from './offlineSettings.js'; // Queue entry status export const QueueStatus = { PENDING: 'pending', PROCESSING: 'processing', COMPLETED: 'completed', FAILED: 'failed' }; // Retry configuration const RETRY_CONFIG = { maxRetries: 5, baseDelay: 1000, // 1 second maxDelay: 30000, // 30 seconds factor: 2, jitter: 0.3 // +/- 30% }; /** * Generate a unique operation ID (idempotency key) */ function generateOperationId() { if (crypto.randomUUID) { return crypto.randomUUID(); } return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0; const v = c === 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Calculate delay with exponential backoff and jitter */ function calculateDelay(retryCount) { const delay = Math.min( RETRY_CONFIG.baseDelay * Math.pow(RETRY_CONFIG.factor, retryCount), RETRY_CONFIG.maxDelay ); // Add jitter const jitter = delay * RETRY_CONFIG.jitter * (Math.random() * 2 - 1); return Math.round(delay + jitter); } /** * Add operation to sync queue */ export async function queueOperation(operation, workorderId, payload, options = {}) { const entry = { operationId: options.operationId || generateOperationId(), operation, workorderId, payload, status: QueueStatus.PENDING, priority: OperationPriority[operation] || 99, retryCount: 0, createdAt: Date.now(), clientTimestamp: Date.now(), deviceId: getDeviceId(), lastAttempt: null, nextAttempt: null, error: null }; const localId = await db.syncQueue.add(entry); console.log(`[SyncQueue] Queued operation: ${operation} for workorder ${workorderId}, localId: ${localId}`); return { localId, operationId: entry.operationId }; } /** * Get all pending operations sorted by priority */ export async function getPendingOperations() { const now = Date.now(); const pending = await db.syncQueue .where('status') .equals(QueueStatus.PENDING) .toArray(); // Filter out operations that are scheduled for later (retry delay) const ready = pending.filter(op => !op.nextAttempt || op.nextAttempt <= now); // Sort by priority (lower = higher priority), then by createdAt return ready.sort((a, b) => { if (a.priority !== b.priority) { return a.priority - b.priority; } return a.createdAt - b.createdAt; }); } /** * Get operations for a specific workorder */ export async function getWorkorderOperations(workorderId) { return db.syncQueue .where('workorderId') .equals(workorderId) .toArray(); } /** * Get pending count */ export async function getPendingCount() { return db.syncQueue .where('status') .equals(QueueStatus.PENDING) .count(); } /** * Get failed operations */ export async function getFailedOperations() { return db.syncQueue .where('status') .equals(QueueStatus.FAILED) .toArray(); } /** * Mark operation as processing */ export async function markProcessing(localId) { await db.syncQueue.update(localId, { status: QueueStatus.PROCESSING, lastAttempt: Date.now() }); } /** * Mark operation as completed */ export async function markCompleted(localId, serverResponse = null) { await db.syncQueue.update(localId, { status: QueueStatus.COMPLETED, serverResponse, completedAt: Date.now() }); console.log(`[SyncQueue] Operation ${localId} completed`); } /** * Mark operation as failed with retry scheduling */ export async function markFailed(localId, error, isRetriable = true) { const operation = await db.syncQueue.get(localId); if (!operation) return; const newRetryCount = operation.retryCount + 1; if (isRetriable && newRetryCount < RETRY_CONFIG.maxRetries) { // Schedule retry const delay = calculateDelay(newRetryCount); await db.syncQueue.update(localId, { status: QueueStatus.PENDING, // Back to pending for retry retryCount: newRetryCount, lastAttempt: Date.now(), nextAttempt: Date.now() + delay, error: error?.message || String(error) }); console.log(`[SyncQueue] Operation ${localId} failed, retry ${newRetryCount}/${RETRY_CONFIG.maxRetries} in ${delay}ms`); } else { // Max retries reached or non-retriable error await db.syncQueue.update(localId, { status: QueueStatus.FAILED, retryCount: newRetryCount, lastAttempt: Date.now(), error: error?.message || String(error) }); console.log(`[SyncQueue] Operation ${localId} permanently failed after ${newRetryCount} attempts`); } } /** * Check if error is retriable * Retriable: 408, 429, 500, 502, 503, 504, network errors * Non-retriable: 400, 401, 403, 404, 409, 422 */ export function isRetriableError(status, error) { // Network errors are retriable if (!status && error) { return true; } // Retriable status codes const retriableCodes = [408, 429, 500, 502, 503, 504]; return retriableCodes.includes(status); } /** * Remove completed operations older than specified time */ export async function cleanupCompleted(maxAgeMs = 24 * 60 * 60 * 1000) { const cutoff = Date.now() - maxAgeMs; const count = await db.syncQueue .where('status') .equals(QueueStatus.COMPLETED) .filter(op => op.completedAt && op.completedAt < cutoff) .delete(); console.log(`[SyncQueue] Cleaned up ${count} completed operations`); return count; } /** * Retry a failed operation */ export async function retryOperation(localId) { await db.syncQueue.update(localId, { status: QueueStatus.PENDING, nextAttempt: null, error: null }); console.log(`[SyncQueue] Operation ${localId} marked for retry`); } /** * Retry all failed operations */ export async function retryAllFailed() { const failed = await getFailedOperations(); for (const op of failed) { await retryOperation(op.localId); } return failed.length; } /** * Delete an operation from queue */ export async function deleteOperation(localId) { await db.syncQueue.delete(localId); console.log(`[SyncQueue] Operation ${localId} deleted`); } /** * Delete all operations for a workorder (e.g., when reassigned) */ export async function deleteWorkorderOperations(workorderId) { const count = await db.syncQueue .where('workorderId') .equals(workorderId) .delete(); console.log(`[SyncQueue] Deleted ${count} operations for workorder ${workorderId}`); return count; } /** * Get queue statistics */ export async function getQueueStats() { const all = await db.syncQueue.toArray(); const stats = { total: all.length, pending: 0, processing: 0, completed: 0, failed: 0, byOperation: {} }; for (const op of all) { stats[op.status]++; stats.byOperation[op.operation] = (stats.byOperation[op.operation] || 0) + 1; } return stats; } /** * Check if there are pending changes for a workorder */ export async function hasPendingChanges(workorderId) { const count = await db.syncQueue .where('workorderId') .equals(workorderId) .filter(op => op.status === QueueStatus.PENDING || op.status === QueueStatus.PROCESSING) .count(); return count > 0; } /** * Get the optimistic state for a workorder (apply pending operations) */ export async function getOptimisticState(workorderId, baseState) { const operations = await getWorkorderOperations(workorderId); const pendingOps = operations.filter( op => op.status === QueueStatus.PENDING || op.status === QueueStatus.PROCESSING ); let state = { ...baseState }; for (const op of pendingOps) { switch (op.operation) { case OperationType.UPDATE_NOTES: state.additionalInfo = op.payload.additionalInfo; break; case OperationType.SCHEDULE_APPOINTMENT: state.appointmentDate = op.payload.appointmentDate; state.status = 'scheduled'; break; case OperationType.UPDATE_CABLE_DATA: state.cableLength = op.payload.cableLength; state.cableType = op.payload.cableType; break; case OperationType.REQUEST_INTERVENTION: state.status = 'intervention_required'; break; case OperationType.COMPLETE_WORKORDER: state.status = 'documented'; break; } } return state; } export default { QueueStatus, queue: queueOperation, getPending: getPendingOperations, getWorkorderOps: getWorkorderOperations, getPendingCount, getFailed: getFailedOperations, markProcessing, markCompleted, markFailed, isRetriableError, cleanup: cleanupCompleted, retry: retryOperation, retryAll: retryAllFailed, delete: deleteOperation, deleteForWorkorder: deleteWorkorderOperations, getStats: getQueueStats, hasPending: hasPendingChanges, getOptimistic: getOptimisticState };