Files
thetool/public/mobile/shared/syncQueue.js
2026-01-27 10:35:15 +01:00

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
};