350 lines
9.6 KiB
JavaScript
350 lines
9.6 KiB
JavaScript
/**
|
|
* 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
|
|
};
|