Complete API documentation for the offline conflict resolution system.
- OfflineQueueManager Class
- ConflictResolver Class
- React Hooks
- TypeScript Interfaces
- Helper Functions
- Usage Examples
The OfflineQueueManager class manages a queue of mutations that need to be synced when online.
constructor(config?: OfflineQueueConfig)Creates a new OfflineQueueManager instance with optional configuration.
Parameters:
config(optional): Configuration options for the queue manager
Example:
import { OfflineQueueManager } from './utils/offlineQueue';
const queueManager = new OfflineQueueManager({
maxRetries: 5,
baseDelay: 1000,
maxDelay: 60000,
autoProcess: true,
onQueueProcessed: (results) => {
console.log('Queue processed:', results);
},
onMutationSuccess: (mutation) => {
console.log('Mutation succeeded:', mutation.id);
},
onMutationFailed: (mutation, error) => {
console.error('Mutation failed:', mutation.id, error);
},
onStatusChange: (status) => {
console.log('Queue status changed:', status);
},
});addToQueue(mutation: QueuedMutation): voidAdds a mutation to the offline queue.
Parameters:
mutation: The mutation object to queue
Behavior:
- Assigns priority based on mutation type if not provided
- Sorts queue by priority (highest first) and timestamp (oldest first)
- Persists queue to localStorage
- Notifies status change listeners
Example:
queueManager.addToQueue({
id: nanoid(),
type: 'add',
payload: {
id: 'item-123',
name: 'Milk',
quantity: 2,
category: 'Dairy',
notes: 'Organic',
userId: 'user-456',
listId: 'list-789',
createdAt: Date.now(),
},
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
priority: 10,
});async processQueue(): Promise<ProcessingResult>Processes all pending and failed mutations in the queue.
Returns: Promise resolving to processing results
Behavior:
- Checks if already processing (returns early if so)
- Processes mutations in priority order
- Applies exponential backoff for retries
- Detects and handles conflicts
- Cleans up successful mutations
- Updates queue status throughout
Example:
const result = await queueManager.processQueue();
console.log('Processed:', result.successCount);
console.log('Failed:', result.failedCount);
console.log('Time:', result.processingTime + 'ms');Return Type:
interface ProcessingResult {
successCount: number; // Mutations successfully processed
failedCount: number; // Mutations that failed
pendingCount: number; // Mutations still pending
failedMutationIds: string[]; // IDs of failed mutations
processingTime: number; // Total time in milliseconds
}clearQueue(): voidClears all mutations from the queue.
Behavior:
- Removes all mutations from memory and localStorage
- Notifies status change listeners
- Logs the action
Example:
queueManager.clearQueue();
console.log('Queue cleared');getQueuedMutations(): QueuedMutation[]Returns a copy of all queued mutations.
Returns: Array of queued mutations
Example:
const mutations = queueManager.getQueuedMutations();
console.log('Pending:', mutations.filter(m => m.status === 'pending').length);
console.log('Failed:', mutations.filter(m => m.status === 'failed').length);async retryFailed(): Promise<ProcessingResult>Resets all failed mutations to pending and processes the queue.
Returns: Promise resolving to processing results
Behavior:
- Finds all failed mutations
- Resets status to 'pending'
- Resets retry count to 0
- Clears error messages
- Calls processQueue()
Example:
const result = await queueManager.retryFailed();
console.log('Retried', result.successCount, 'failed mutations');getStatus(): QueueStatusReturns the current status of the queue.
Returns: Queue status object
Example:
const status = queueManager.getStatus();
console.log('Total:', status.total);
console.log('Pending:', status.pending);
console.log('Processing:', status.processing);
console.log('Failed:', status.failed);
console.log('Success:', status.success);
console.log('Is Processing:', status.isProcessing);
console.log('Last Processed:', new Date(status.lastProcessed || 0));Return Type:
interface QueueStatus {
total: number; // Total mutations in queue
pending: number; // Pending mutations
processing: number; // Currently processing
failed: number; // Failed mutations
success: number; // Successful mutations
isProcessing: boolean; // Whether processing
lastProcessed?: number; // Last processed timestamp
}removeMutation(mutationId: string): voidRemoves a specific mutation from the queue by ID.
Parameters:
mutationId: ID of the mutation to remove
Example:
queueManager.removeMutation('mut_abc123');interface OfflineQueueConfig {
maxRetries?: number; // Default: 5
baseDelay?: number; // Default: 1000ms
maxDelay?: number; // Default: 60000ms
autoProcess?: boolean; // Default: true
onQueueProcessed?: (results: ProcessingResult) => void;
onMutationSuccess?: (mutation: QueuedMutation) => void;
onMutationFailed?: (mutation: QueuedMutation, error: Error) => void;
onStatusChange?: (status: QueueStatus) => void;
}The ConflictResolver class detects and resolves conflicts between local and remote versions of items.
constructor()Creates a new ConflictResolver instance.
Example:
import { ConflictResolver } from './utils/conflictResolver';
const resolver = new ConflictResolver();detectConflict(local: GroceryItem, remote: GroceryItem): Conflict | nullDetects if there's a conflict between local and remote versions.
Parameters:
local: Local version of the itemremote: Remote version of the item
Returns: Conflict object if detected, null otherwise
Throws:
- Error if inputs are invalid or IDs don't match
Example:
const local = {
id: 'item-123',
name: 'Milk',
quantity: 2,
gotten: false,
category: 'Dairy',
notes: 'Organic',
userId: 'user-456',
listId: 'list-789',
createdAt: 1698765432000,
};
const remote = {
...local,
quantity: 3,
gotten: true,
createdAt: 1698765433000,
};
const conflict = resolver.detectConflict(local, remote);
if (conflict) {
console.log('Conflict detected!');
console.log('Conflicting fields:', conflict.fieldConflicts);
console.log('Requires manual resolution:', conflict.requiresManualResolution);
}Return Type:
interface Conflict {
id: string; // Item ID
type: ConflictType; // Conflict type
local: GroceryItem; // Local version
remote: GroceryItem; // Remote version
fieldConflicts: FieldConflict[]; // Field-level conflicts
detectedAt: number; // Detection timestamp
requiresManualResolution: boolean; // Manual resolution needed?
}
interface FieldConflict {
field: keyof GroceryItem; // Field name
localValue: any; // Local value
remoteValue: any; // Remote value
localTimestamp?: number; // Local timestamp
remoteTimestamp?: number; // Remote timestamp
}resolveConflict(
conflict: Conflict,
strategy: ConflictResolutionStrategy
): GroceryItemResolves a conflict using the specified strategy.
Parameters:
conflict: The conflict to resolvestrategy: Resolution strategy to use
Returns: Resolved GroceryItem
Throws: Error if manual resolution is required but not provided
Strategies:
'last-write-wins': Most recent timestamp wins'field-level-merge': Merge fields intelligently'prefer-local': Keep all local changes'prefer-remote': Keep all remote changes'prefer-gotten': Prefer version with gotten=true'manual': Requires manual intervention (throws error)
Example:
const resolved = resolver.resolveConflict(conflict, 'field-level-merge');
console.log('Resolved item:', resolved);autoResolve(conflict: Conflict): GroceryItem | nullAttempts to automatically resolve a conflict using heuristics.
Parameters:
conflict: The conflict to resolve
Returns: Resolved GroceryItem if successful, null if manual resolution required
Auto-Resolution Rules:
- Prefer "gotten" state (if one is gotten, use that version)
- Use last-write-wins if timestamps differ by >5 minutes
- Use field-level merge if only mergable fields conflict
- Use higher quantity if both users increased it
Example:
const resolved = resolver.autoResolve(conflict);
if (resolved) {
console.log('Auto-resolved:', resolved);
applyResolution(resolved);
} else {
console.log('Manual resolution required');
showConflictDialog(conflict);
}mergeFields(local: GroceryItem, remote: GroceryItem): GroceryItemIntelligently merges non-conflicting fields from both versions.
Parameters:
local: Local versionremote: Remote version
Returns: Merged GroceryItem
Merge Strategy:
gotten: Always prefertrue(someone got the item)quantity: Use higher value (someone needed more)notes: Concatenate with " | " separator- Other fields: Use most recent based on timestamp
Example:
const merged = resolver.mergeFields(local, remote);
console.log('Merged item:', merged);React hook for accessing the offline queue.
function useOfflineQueue(config?: OfflineQueueConfig): UseOfflineQueueReturnParameters:
config(optional): Queue configuration
Returns: Object with queue status and control functions
Example:
import { useOfflineQueue } from './utils/offlineQueue';
function MyComponent() {
const {
// Status
queueStatus,
pendingCount,
failedCount,
successCount,
totalCount,
isProcessing,
lastProcessed,
lastUpdate,
// Actions
retryFailed,
clearQueue,
processQueue,
addMutation,
removeMutation,
getQueuedMutations,
// Direct access
queueManager,
} = useOfflineQueue();
return (
<div>
<p>Pending: {pendingCount}</p>
<p>Failed: {failedCount}</p>
{isProcessing && <p>Syncing...</p>}
{failedCount > 0 && (
<button onClick={retryFailed}>
Retry Failed ({failedCount})
</button>
)}
<button onClick={clearQueue}>
Clear Queue
</button>
</div>
);
}Return Type:
interface UseOfflineQueueReturn {
// Status
queueStatus: QueueStatus;
pendingCount: number;
failedCount: number;
successCount: number;
totalCount: number;
isProcessing: boolean;
lastProcessed?: number;
lastUpdate: number;
// Actions
retryFailed: () => Promise<void>;
clearQueue: () => void;
processQueue: () => Promise<void>;
addMutation: (mutation: Omit<QueuedMutation, 'id' | 'timestamp' | 'retryCount' | 'status'>) => void;
removeMutation: (mutationId: string) => void;
getQueuedMutations: () => QueuedMutation[];
// Direct access
queueManager: OfflineQueueManager;
}Hook for querying and mutating grocery items (from zero-store).
function useGroceryItems(listId: string): UseGroceryItemsReturnParameters:
listId: ID of the list to query
Returns: Object with items and mutation functions
Example:
import { useGroceryItems } from './hooks/useGroceryItems';
function GroceryList({ listId }: { listId: string }) {
const { items, addItem, updateItem, deleteItem, markGotten } = useGroceryItems(listId);
const handleAdd = async () => {
await addItem({
name: 'Milk',
quantity: 2,
category: 'Dairy',
notes: 'Organic',
});
};
return (
<div>
<button onClick={handleAdd}>Add Item</button>
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} ({item.quantity})
<button onClick={() => markGotten(item.id, !item.gotten)}>
{item.gotten ? 'Undo' : 'Mark Gotten'}
</button>
<button onClick={() => deleteItem(item.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}interface QueuedMutation {
id: string; // Unique mutation ID
type: MutationType; // Mutation type
payload: any; // Mutation data
timestamp: number; // Creation time
retryCount: number; // Retry attempts
status: MutationStatus; // Current status
error?: string; // Error message
priority?: number; // Queue priority
}
type MutationType = 'add' | 'update' | 'delete' | 'markGotten';
type MutationStatus = 'pending' | 'processing' | 'failed' | 'success';interface Conflict {
id: string; // Item ID
type: ConflictType; // Conflict type
local: GroceryItem; // Local version
remote: GroceryItem; // Remote version
fieldConflicts: FieldConflict[]; // Field conflicts
detectedAt: number; // Detection time
requiresManualResolution: boolean; // Manual needed?
}
type ConflictType = 'field' | 'delete' | 'concurrent_edit';
interface FieldConflict {
field: keyof GroceryItem;
localValue: any;
remoteValue: any;
localTimestamp?: number;
remoteTimestamp?: number;
}type ConflictResolutionStrategy =
| 'last-write-wins'
| 'field-level-merge'
| 'prefer-local'
| 'prefer-remote'
| 'prefer-gotten'
| 'manual';interface GroceryItem {
id: string;
name: string;
quantity: number;
gotten: boolean;
category: Category;
notes: string;
userId: string;
listId: string;
createdAt: number;
}
type Category =
| 'Produce'
| 'Dairy'
| 'Meat'
| 'Bakery'
| 'Pantry'
| 'Frozen'
| 'Beverages'
| 'Other';enum ConflictResolutionStrategy {
LastWriteWins = 'LAST_WRITE_WINS',
Manual = 'MANUAL',
FieldMerge = 'FIELD_MERGE',
Custom = 'CUSTOM',
}
enum ConflictType {
UpdateUpdate = 'UPDATE_UPDATE', // Both modified
UpdateDelete = 'UPDATE_DELETE', // Local updated, remote deleted
DeleteUpdate = 'DELETE_UPDATE', // Local deleted, remote updated
CreateCreate = 'CREATE_CREATE', // Both created with same ID
}
interface ChangeMetadata {
userId: string;
userName: string;
timestamp: number;
deviceId?: string;
version?: number;
}
interface Conflict<T = GroceryItem> {
id: string;
type: ConflictType;
entityId: string;
entityType: string;
baseVersion: T | null; // Common ancestor
localVersion: T | null;
remoteVersion: T | null;
localMetadata: ChangeMetadata;
remoteMetadata: ChangeMetadata;
fieldChanges: FieldChange[];
detectedAt: number;
resolutionStrategy: ConflictResolutionStrategy;
resolved: boolean;
resolvedVersion?: T;
resolvedAt?: number;
resolvedBy?: string;
context?: Record<string, any>;
}
interface FieldChange<T = any> {
field: string;
baseValue: T;
localValue: T;
remoteValue: T;
hasConflict: boolean;
resolvedValue?: T;
}enum ConnectionStatus {
Online = 'ONLINE',
Offline = 'OFFLINE',
Connecting = 'CONNECTING',
Unknown = 'UNKNOWN',
}
enum SyncState {
Idle = 'IDLE',
Syncing = 'SYNCING',
Synced = 'SYNCED',
Failed = 'FAILED',
Conflicts = 'CONFLICTS',
}
interface SyncStatus {
connectionStatus: ConnectionStatus;
syncState: SyncState;
lastSyncedAt: number | null;
lastSyncAttempt: number | null;
pendingChanges: number;
unresolvedConflicts: number;
syncProgress: number;
errorMessage?: string;
autoSyncEnabled: boolean;
nextSyncAt?: number;
}function createAddItemMutation(
item: Omit<GroceryItem, 'id' | 'gotten' | 'createdAt'> & { id: string }
): Omit<QueuedMutation, 'id' | 'timestamp' | 'retryCount' | 'status'>Creates a mutation object for adding an item.
Example:
import { createAddItemMutation } from './utils/offlineQueue';
import { nanoid } from 'nanoid';
const mutation = createAddItemMutation({
id: nanoid(),
name: 'Milk',
quantity: 2,
category: 'Dairy',
notes: 'Organic',
userId: 'user-123',
listId: 'list-456',
});
queueManager.addToQueue({
...mutation,
id: nanoid(),
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
});function createUpdateItemMutation(
id: string,
updates: Partial<Omit<GroceryItem, 'id'>>
): Omit<QueuedMutation, 'id' | 'timestamp' | 'retryCount' | 'status'>Creates a mutation object for updating an item.
Example:
import { createUpdateItemMutation } from './utils/offlineQueue';
const mutation = createUpdateItemMutation('item-123', {
quantity: 3,
notes: 'Low fat',
});
queueManager.addToQueue({
...mutation,
id: nanoid(),
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
});function createMarkGottenMutation(
id: string,
gotten: boolean
): Omit<QueuedMutation, 'id' | 'timestamp' | 'retryCount' | 'status'>Creates a mutation object for marking an item as gotten/not gotten.
Example:
import { createMarkGottenMutation } from './utils/offlineQueue';
const mutation = createMarkGottenMutation('item-123', true);
queueManager.addToQueue({
...mutation,
id: nanoid(),
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
});function createDeleteItemMutation(
id: string
): Omit<QueuedMutation, 'id' | 'timestamp' | 'retryCount' | 'status'>Creates a mutation object for deleting an item.
Example:
import { createDeleteItemMutation } from './utils/offlineQueue';
const mutation = createDeleteItemMutation('item-123');
queueManager.addToQueue({
...mutation,
id: nanoid(),
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
});function getQueueManager(config?: OfflineQueueConfig): OfflineQueueManagerReturns the singleton queue manager instance.
Example:
import { getQueueManager } from './utils/offlineQueue';
const queueManager = getQueueManager({
maxRetries: 5,
baseDelay: 1000,
});
// Subsequent calls return the same instance
const sameInstance = getQueueManager();
console.log(queueManager === sameInstance); // truefunction createConflictResolver(): ConflictResolverCreates a new ConflictResolver instance.
Example:
import { createConflictResolver } from './utils/conflictResolver';
const resolver = createConflictResolver();
const conflict = resolver.detectConflict(local, remote);function compareTimestamps(t1: number, t2: number): numberCompares two timestamps.
Returns:
- Positive number if t1 > t2
- Negative number if t1 < t2
- Zero if equal
Example:
import { compareTimestamps } from './utils/conflictResolver';
const result = compareTimestamps(1698765432000, 1698765433000);
console.log(result); // -1000 (t1 is older)function hasConflict(local: any, remote: any): booleanChecks if two values represent a conflict.
Returns: true if values conflict, false otherwise
Example:
import { hasConflict } from './utils/conflictResolver';
console.log(hasConflict('apple', 'orange')); // true
console.log(hasConflict('apple', 'apple')); // false
console.log(hasConflict(null, undefined)); // false
console.log(hasConflict('', null)); // falsefunction logConflict(conflict: Conflict): voidLogs conflict information to console for debugging.
Example:
import { logConflict } from './utils/conflictResolver';
const conflict = resolver.detectConflict(local, remote);
logConflict(conflict);
// Outputs formatted conflict details to consoleimport { useOfflineQueue, createAddItemMutation } from './utils/offlineQueue';
import { nanoid } from 'nanoid';
function AddItemOffline() {
const { addMutation, pendingCount } = useOfflineQueue();
const isOnline = navigator.onLine;
const handleAddItem = () => {
const mutation = createAddItemMutation({
id: nanoid(),
name: 'Milk',
quantity: 2,
category: 'Dairy',
notes: 'Organic',
userId: 'user-123',
listId: 'list-456',
});
if (!isOnline) {
// Queue for later when online
addMutation(mutation);
} else {
// Direct sync when online
zero.mutate.grocery_items.create(mutation.payload);
}
};
return (
<div>
<button onClick={handleAddItem}>Add Item</button>
{pendingCount > 0 && (
<span>({pendingCount} queued)</span>
)}
</div>
);
}import { ConflictResolver } from './utils/conflictResolver';
import { GroceryItem } from './types';
async function syncItemWithConflictResolution(
localItem: GroceryItem,
remoteItem: GroceryItem
) {
const resolver = new ConflictResolver();
// Detect conflict
const conflict = resolver.detectConflict(localItem, remoteItem);
if (!conflict) {
// No conflict, use remote version
return remoteItem;
}
// Try auto-resolution
const resolved = resolver.autoResolve(conflict);
if (resolved) {
console.log('Auto-resolved conflict');
return resolved;
}
// Manual resolution required
console.log('Manual resolution needed');
const userChoice = await showConflictDialog(conflict);
switch (userChoice.strategy) {
case 'keep-local':
return conflict.local;
case 'keep-remote':
return conflict.remote;
case 'merge':
return resolver.mergeFields(conflict.local, conflict.remote);
case 'custom':
return userChoice.customVersion;
}
}import { getQueueManager } from './utils/offlineQueue';
const queueManager = getQueueManager({
maxRetries: 3,
baseDelay: 2000,
onQueueProcessed: (results) => {
if (results.failedCount > 0) {
notifyUser(`${results.failedCount} items failed to sync`);
} else {
notifyUser(`All ${results.successCount} items synced successfully`);
}
},
onMutationFailed: (mutation, error) => {
console.error('Mutation failed:', mutation.type, error.message);
if (mutation.retryCount >= 3) {
// Max retries reached, notify user
notifyUser(`Failed to sync ${mutation.payload.name} after 3 attempts`);
}
},
});
// Manual processing trigger
document.getElementById('sync-btn')?.addEventListener('click', async () => {
const result = await queueManager.processQueue();
console.log('Sync complete:', result);
});import { useOfflineQueue } from './utils/offlineQueue';
import { SyncStatus } from './components/SyncStatus';
function App() {
const {
pendingCount,
isProcessing,
lastProcessed,
processQueue,
} = useOfflineQueue();
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => {
setIsOnline(true);
processQueue(); // Auto-sync when coming online
};
const handleOffline = () => {
setIsOnline(false);
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [processQueue]);
return (
<div>
<SyncStatus
isOnline={isOnline}
isSyncing={isProcessing}
queuedCount={pendingCount}
lastSyncTime={lastProcessed ? new Date(lastProcessed) : null}
onRetrySync={processQueue}
/>
{/* Rest of app */}
</div>
);
}import { ConflictResolver } from './utils/conflictResolver';
const resolver = new ConflictResolver();
// Extend with custom merge logic
class CustomConflictResolver extends ConflictResolver {
mergeFields(local: GroceryItem, remote: GroceryItem): GroceryItem {
const merged = super.mergeFields(local, remote);
// Custom logic: If both users changed category, use local
if (local.category !== remote.category) {
merged.category = local.category;
}
// Custom logic: Merge notes with timestamps
if (local.notes !== remote.notes) {
merged.notes = [
`Local (${new Date(local.createdAt).toLocaleString()}): ${local.notes}`,
`Remote (${new Date(remote.createdAt).toLocaleString()}): ${remote.notes}`,
].join('\n');
}
return merged;
}
}
const customResolver = new CustomConflictResolver();import { getQueueManager, createAddItemMutation } from './utils/offlineQueue';
import { nanoid } from 'nanoid';
async function addMultipleItemsOffline(items: Array<{
name: string;
quantity: number;
category: Category;
}>) {
const queueManager = getQueueManager();
// Queue all items
items.forEach(item => {
const mutation = createAddItemMutation({
id: nanoid(),
...item,
notes: '',
userId: 'user-123',
listId: 'list-456',
});
queueManager.addToQueue({
...mutation,
id: nanoid(),
timestamp: Date.now(),
retryCount: 0,
status: 'pending',
});
});
console.log(`Queued ${items.length} items`);
// Process when online
if (navigator.onLine) {
const result = await queueManager.processQueue();
console.log(`Synced ${result.successCount}/${items.length} items`);
}
}try {
await queueManager.processQueue();
} catch (error) {
if (error instanceof NetworkError) {
console.error('Network error:', error.message);
// Will retry automatically with exponential backoff
}
}try {
queueManager.addToQueue(mutation);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.error('Storage quota exceeded');
// Clean up old entries
queueManager.clearQueue();
// Notify user
alert('Storage full. Some offline changes were cleared.');
}
}try {
const resolved = resolver.autoResolve(conflict);
if (!resolved) {
throw new Error('Manual resolution required');
}
} catch (error) {
console.log('Conflict requires manual resolution');
showConflictDialog(conflict);
}-
Always check online status before queuing:
if (!navigator.onLine) { queueManager.addToQueue(mutation); } else { await directSync(mutation); }
-
Use helper functions for creating mutations:
const mutation = createAddItemMutation(item); // ✓ Good // vs const mutation = { type: 'add', payload: { ... } }; // ✗ Bad
-
Handle callback errors gracefully:
const queueManager = new OfflineQueueManager({ onMutationFailed: (mutation, error) => { try { logError(mutation, error); } catch (e) { // Don't let callback errors break queue processing console.error('Error in callback:', e); } }, });
-
Clean up successful mutations:
// Automatic cleanup after successful sync queue = queue.filter(m => m.status !== 'success');
-
Monitor queue size:
const status = queueManager.getStatus(); if (status.total > 100) { console.warn('Queue getting large:', status.total); }
Version: 1.0.0 Last Updated: October 2025