Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 41 additions & 36 deletions src/conflict-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,65 @@
*/

/**
* Resolve conflicts between offline and server data
* Uses timestamp-based "last write wins" strategy with UUID validation
* Resolves conflict using a specific strategy.
* Supports: 'server-wins' (default), 'client-wins', and 'merge' (with user prompt).
* @param {Object} localAction - Action stored offline
* @param {Object} serverData - Current server state
* @returns {Object} - Resolved action to apply
* @param {'server-wins'|'client-wins'|'merge'} strategy - Strategy to apply
* @returns {Promise<Object|null>} - Resolved action to apply or null if discarded
*/
/**
* Resolves an offline/online data conflict using a server-wins merge strategy.
* Local actions are applied on top of server data where no direct field clash exists.
* @param {Object} localAction - The pending local action with type and payload fields.
* @param {Object} serverData - The authoritative server-side data snapshot.
* @returns {{ resolved: Object, conflict: boolean }} Merged result and conflict flag.
*/
export function resolveConflict(localAction, serverData) {
export async function resolveConflict(localAction, serverData, strategy = 'server-wins') {
// If no server data exists, local action wins
if (!serverData) {
console.debug(`[ConflictResolver] No server data — local action applied: ${localAction.id}`);
return localAction;
}

// UUID duplicate check — if same ID already on server, skip
if (serverData.id === localAction.id) {
if (serverData.id === localAction.id && !localAction.forceUpdate) {
console.warn(`[ConflictResolver] Duplicate detected — skipping: ${localAction.id}`);
return null;
}

// Timestamp check — newer data wins
if (serverData.timestamp > localAction.timestamp) {
console.warn(`[ConflictResolver] Server data is newer — discarding local: ${localAction.id}`);
return null;
if (strategy === 'client-wins') {
console.debug(`[ConflictResolver] Client-Wins strategy applied: ${localAction.id}`);
return localAction;
}

if (strategy === 'server-wins') {
if (serverData.timestamp > localAction.timestamp) {
console.warn(`[ConflictResolver] Server-Wins: Server is newer. Discarding local: ${localAction.id}`);
return null;
}
return localAction;
}

if (strategy === 'merge') {
console.debug(`[ConflictResolver] Merge strategy for: ${localAction.id}`);
const mergedPayload = { ...serverData, ...localAction.payload };

// Prompt user on critical conflicts (status discrepancy)
if (serverData.status && localAction.payload.status && serverData.status !== localAction.payload.status) {
if (typeof window !== 'undefined' && window.confirm) {
const keepLocal = window.confirm(
`Conflict on dispatch ${localAction.id || ''}:\n` +
`Server status: "${serverData.status}" vs Local status: "${localAction.payload.status}".\n` +
`Do you want to override the server with your offline changes?`
);
if (!keepLocal) {
return null; // Keep server data, discard local update
}
}
}
return {
...localAction,
payload: mergedPayload
};
}

console.debug(`[ConflictResolver] Local action is newer — applying: ${localAction.id}`);
return localAction;
}

/**
* Check if an action is a duplicate in the pending queue
* @param {Array} pendingActions - All queued offline actions
* @param {string} type - Action type to check
* @param {Object} payload - Action payload to compare
* @returns {boolean}
*/
/**
* Checks whether an identical action already exists in the pending offline queue.
* Used to prevent duplicate writes during intermittent connectivity.
Expand All @@ -63,11 +79,6 @@ export function isDuplicate(pendingActions, type, payload) {
);
}

/**
* Merge offline GPS updates — keep only the latest location
* @param {Array} actions - All pending GPS actions
* @returns {Array} - Deduplicated actions (latest GPS only)
*/
/**
* Deduplicates and merges GPS location update actions from the offline queue.
* Keeps only the most recent update per rider, discarding stale duplicates.
Expand All @@ -89,17 +100,11 @@ export function mergeGPSUpdates(actions) {
return [...otherActions, latestGPS];
}

/**
* Validate action before queuing — prevent invalid data
* @param {string} type - Action type
* @param {Object} payload - Action data
* @returns {boolean}
*/
/**
* Validates an offline action's payload against the required schema for its type.
* @param {string} type - The action type (e.g. 'ORDER_UPDATE', 'GPS_UPDATE').
* @param {Object} payload - The action payload to validate.
* @returns {{ valid: boolean, errors: string[] }} Validation result with any error messages.
* @returns {boolean} Validation result.
*/
export function validateAction(type, payload) {
const validTypes = ['dispatch', 'pickup', 'gps', 'scan', 'reward', 'plant'];
Expand Down
27 changes: 26 additions & 1 deletion src/offline-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Handles offline action queuing, background sync, and retry logic
*/

import { resolveConflict } from './conflict-resolver.js';

const DB_NAME = 'ReGenX_OfflineDB';
const DB_VERSION = 1;
const STORE_NAME = 'pendingActions';
Expand Down Expand Up @@ -152,7 +154,30 @@ export async function syncPendingActions() {

for (const action of actions) {
try {
await processAction(action);
// Fetch current server state of the record to check for conflicts
let serverData = null;
try {
const recordId = action.payload?.id || action.id;
const res = await fetch(`/api/records/${recordId}`);
if (res.ok) {
serverData = await res.json();
}
} catch (e) {
console.warn(`[OfflineSync] Could not fetch server state for conflict check:`, e);
}

let actionToProcess = action;
if (serverData) {
const resolved = await resolveConflict(action, serverData, 'merge');
if (!resolved) {
console.log(`[OfflineSync] Action discarded due to conflict resolution: ${action.id}`);
await removeAction(action.id);
continue;
}
actionToProcess = resolved;
}

await processAction(actionToProcess);
await removeAction(action.id);
console.log(`[OfflineSync] Synced: ${action.type} (${action.id})`);
} catch (error) {
Expand Down