Offline Sync Phase17 - nself-org/nchat GitHub Wiki
Version: 0.9.0 Status: ✅ Complete Tasks: 118-120
Phase 17 implements comprehensive offline support with automatic synchronization, conflict resolution, and optimistic UI updates. Users can continue working while offline, with all changes automatically synced when connectivity is restored.
Messages sent while offline are queued and automatically sent when connectivity is restored.
import { offlineDB } from '@/lib/offline/indexeddb'
// Add message to queue
await offlineDB.addToMessageQueue({
id: 'temp-msg-123',
channelId: 'channel-1',
content: 'Message sent offline',
contentType: 'text',
createdAt: Date.now(),
attempts: 0,
status: 'pending',
})
// Get pending messages
const pending = await offlineDB.getMessageQueue('pending')
// Update message status
await offlineDB.updateMessageQueueItem('temp-msg-123', {
status: 'syncing',
attempts: 1,
})
// Remove after sync
await offlineDB.removeFromMessageQueue('temp-msg-123')// Queue file upload
const file = new File(['content'], 'document.pdf')
await offlineDB.addToUploadQueue({
id: 'upload-123',
file,
channelId: 'channel-1',
progress: 0,
attempts: 0,
status: 'pending',
})
// Track upload progress
await offlineDB.updateUploadQueueItem('upload-123', {
progress: 50,
status: 'uploading',
})// Cache message for offline viewing
await offlineDB.cacheMessage({
id: 'msg-123',
channelId: 'channel-1',
content: 'Cached message',
userId: 'user-1',
createdAt: Date.now(),
version: 1,
lastSynced: Date.now(),
})
// Retrieve cached messages
const messages = await offlineDB.getCachedMessages('channel-1')Automatically resolves conflicts when offline edits conflict with server changes.
-
Last Write Wins (Default)
- Uses the most recently modified version
- Best for simple updates
-
Server Wins
- Always uses server version
- Best for authoritative server data
-
Client Wins
- Always uses local version
- Best for user preferences
-
Three-Way Merge
- Merges compatible changes
- Best for complex objects
-
Manual Resolution
- Prompts user to choose
- Best for critical conflicts
import { ConflictResolver } from '@/lib/offline/conflict-resolver'
const resolver = new ConflictResolver()
// Auto-resolve conflict
const conflict = {
id: 'msg-123',
type: 'concurrent_edit',
itemType: 'message',
local: { content: 'Local edit', updatedAt: Date.now() },
remote: { content: 'Server edit', updatedAt: Date.now() - 5000 },
localTimestamp: new Date(),
remoteTimestamp: new Date(Date.now() - 5000),
}
const resolution = await resolver.autoResolve(conflict)
if (resolution.resolved) {
console.log('Conflict resolved:', resolution.result)
} else {
console.log('Manual resolution needed')
}const base = { theme: 'light', lang: 'en' }
const local = { theme: 'dark', lang: 'en' } // Changed theme
const server = { theme: 'light', lang: 'fr' } // Changed language
const conflict = {
id: 'settings',
type: 'concurrent_edit',
itemType: 'settings',
local,
remote: server,
ancestor: base,
localTimestamp: new Date(),
remoteTimestamp: new Date(),
}
const resolution = await resolver.resolve(conflict, 'merge')
// Result: { theme: 'dark', lang: 'fr' }
// Merged both changes!User settings automatically sync across devices with conflict resolution.
import { useSettingsSync } from '@/hooks/use-settings-sync'
function SettingsPage() {
const {
settings,
isLoading,
isSyncing,
hasUnsyncedChanges,
conflict,
updateSettings,
syncSettings,
resolveConflict,
} = useSettingsSync()
// Update settings
const handleThemeChange = async (theme: string) => {
await updateSettings({
theme: { preset: theme },
})
// Auto-syncs in background
}
// Manually trigger sync
const handleSync = async () => {
await syncSettings()
}
// Resolve conflict
const handleResolve = async () => {
if (conflict) {
await resolveConflict('local') // or 'server' or 'custom'
}
}
return (
<div>
{hasUnsyncedChanges && (
<button onClick={handleSync}>Sync Now</button>
)}
{conflict && (
<div>
<p>Conflict detected!</p>
<button onClick={() => handleResolve()}>
Use Local Version
</button>
</div>
)}
</div>
)
}interface UserSettings {
userId: string
theme: {
mode: 'light' | 'dark' | 'system'
preset: string
customColors?: Record<string, string>
}
notifications: {
enabled: boolean
sound: boolean
desktop: boolean
email: boolean
channels: Record<string, boolean>
}
preferences: {
language: string
timezone: string
dateFormat: string
timeFormat: '12h' | '24h'
compactMode: boolean
showAvatars: boolean
emojiStyle: 'native' | 'twitter' | 'google'
}
privacy: {
showOnlineStatus: boolean
showReadReceipts: boolean
allowDirectMessages: boolean
}
accessibility: {
fontSize: 'small' | 'medium' | 'large'
highContrast: boolean
reduceMotion: boolean
screenReaderOptimized: boolean
}
version: number
updatedAt: Date
syncedAt?: Date
}Messages appear instantly in the UI while syncing in the background.
import { useOptimisticMessages } from '@/hooks/use-optimistic-messages'
function MessageInput({ channelId }: { channelId: string }) {
const { sendMessage, optimisticMessages, pendingCount } = useOptimisticMessages(channelId)
const handleSend = async (content: string) => {
// Message appears in UI immediately
await sendMessage({
channelId,
content,
contentType: 'text',
})
// Syncs in background
}
return (
<div>
{pendingCount > 0 && (
<div>Sending {pendingCount} messages...</div>
)}
<input
onKeyPress={(e) => {
if (e.key === 'Enter' && e.currentTarget.value) {
handleSend(e.currentTarget.value)
e.currentTarget.value = ''
}
}}
/>
</div>
)
}- Optimistic: Message just sent, showing in UI
- Sending: Message being sent to server
- Sent: Message successfully delivered
- Failed: Message failed to send (with retry option)
Background service that automatically syncs when online.
import { syncService } from '@/lib/offline/sync-service'
// Configure sync options
syncService.configure({
maxRetries: 3,
retryDelay: 1000,
maxRetryDelay: 30000,
batchSize: 10,
})
// Listen to sync events
syncService.addListener((status, progress) => {
console.log('Sync status:', status)
if (progress) {
console.log(`Progress: ${progress.completed}/${progress.total}`)
}
})
// Start auto-sync
syncService.startAutoSync(30000) // Every 30 seconds
// Manual sync
await syncService.sync()
// Get queue statistics
const stats = await syncService.getQueueStats()
console.log('Pending messages:', stats.messages.pending)
console.log('Failed uploads:', stats.uploads.failed)
// Retry failed items
await syncService.retryFailed()Service worker automatically syncs data when connectivity is restored, even if the app is closed.
// In your app initialization
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready
// Register background sync
await registration.sync.register('sync-messages')
await registration.sync.register('sync-uploads')
await registration.sync.register('sync-settings')
}The service worker automatically:
- Syncs pending messages
- Syncs pending uploads
- Syncs user settings
- Notifies the app of sync results
// In your React component
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data.type === 'SYNC_COMPLETED') {
console.log('Background sync completed:', event.data.category)
console.log('Success:', event.data.success)
console.log('Failed:', event.data.failed)
}
})
}
}, [])Visual indicator showing connection status and pending operations.
import { OfflineIndicator } from '@/components/ui/offline-indicator'
function AppLayout() {
return (
<>
<OfflineIndicator
position="top"
detailed={true}
dismissible={false}
autoHide={true}
/>
{/* Your app content */}
</>
)
}import { OfflineIndicatorCompact } from '@/components/ui/offline-indicator'
function AppLayout() {
return (
<>
{/* Your app content */}
<OfflineIndicatorCompact />
</>
)
}import { OfflineBanner } from '@/components/ui/offline-indicator'
function AppLayout() {
return (
<>
<OfflineBanner />
{/* Your app content */}
</>
)
}View and manage queued operations.
import { OfflineQueueViewer } from '@/components/offline/offline-queue-viewer'
function SettingsPage() {
const [showQueue, setShowQueue] = useState(false)
return (
<>
<button onClick={() => setShowQueue(true)}>
View Offline Queue
</button>
<OfflineQueueViewer
asDialog
open={showQueue}
onClose={() => setShowQueue(false)}
/>
</>
)
}// Message Queue
await offlineDB.addToMessageQueue(message)
await offlineDB.getMessageQueue(status?)
await offlineDB.updateMessageQueueItem(id, updates)
await offlineDB.removeFromMessageQueue(id)
await offlineDB.clearMessageQueue()
// Upload Queue
await offlineDB.addToUploadQueue(upload)
await offlineDB.getUploadQueue(status?)
await offlineDB.updateUploadQueueItem(id, updates)
await offlineDB.removeFromUploadQueue(id)
// Message Cache
await offlineDB.cacheMessage(message)
await offlineDB.getCachedMessages(channelId)
await offlineDB.getCachedMessage(id)
// Sync Metadata
await offlineDB.setSyncMetadata(metadata)
await offlineDB.getSyncMetadata(entityType, entityId)
await offlineDB.getConflicts()
await offlineDB.resolveConflict(entityType, entityId)
// Settings
await offlineDB.saveSettings(settings)
await offlineDB.getSettings(userId)
// Utilities
await offlineDB.getStorageEstimate()
await offlineDB.clearAll()
offlineDB.close()// Configuration
syncService.configure(options)
// Events
syncService.addListener(callback)
// Sync
await syncService.sync()
syncService.startAutoSync(intervalMs)
syncService.stopAutoSync()
syncService.pauseSync()
// Queue Management
await syncService.getQueueStats()
await syncService.retryFailed()
await syncService.clearQueues()
// Status
const status = syncService.getStatus()
const isOnline = syncService.isConnected()
// Cleanup
syncService.destroy()// Resolve Conflict
const resolution = await resolver.resolve(conflict, strategy)
const resolution = await resolver.autoResolve(conflict)
// Batch Resolution
const resolutions = await resolver.resolveMany(conflicts, strategy)
// Conflict Detection
const conflict = resolver.detectConflict(local, remote)
// Conflict Summary
const summary = resolver.getConflictSummary(conflict)
// Manual Resolution Callback
resolver.setUserChoiceCallback(async (conflict) => {
// Show UI and return user's choice
return selectedVersion
})✅ Do:
- Use optimistic updates for instant feedback
- Queue messages when offline
- Show sync status to users
- Handle failures gracefully
- Retry failed operations
❌ Don't:
- Block UI waiting for sync
- Lose messages on failure
- Hide sync failures from users
✅ Do:
- Use appropriate strategy for data type
- Provide manual resolution for critical data
- Log conflicts for debugging
- Test conflict scenarios
❌ Don't:
- Silently overwrite user data
- Ignore conflicts
- Use complex strategies for simple data
✅ Do:
- Sync settings periodically
- Use debouncing for frequent updates
- Version settings for conflict detection
- Validate settings before sync
❌ Don't:
- Sync on every keystroke
- Overwrite newer settings
- Sync sensitive data without encryption
✅ Do:
- Batch sync operations
- Use background sync when available
- Limit cache size
- Clean up old data
❌ Don't:
- Sync everything at once
- Keep unlimited cache
- Sync on every network change
# Run offline tests
npm test src/lib/offline/__tests__/offline-phase17.test.ts
# Test with network throttling
# Chrome DevTools -> Network -> Throttling -> Offline
# Test background sync
# Chrome DevTools -> Application -> Service Workers -> Sync- Check network connection
- Verify service worker is active
- Check IndexedDB for queued messages
- Check browser console for errors
// Debug queue
const queue = await offlineDB.getMessageQueue()
console.log('Pending messages:', queue)
// Force sync
await syncService.sync()- Check conflict strategy
- Verify sync metadata exists
- Check for errors in resolution
// Debug conflicts
const conflicts = await offlineDB.getConflicts()
console.log('Conflicts:', conflicts)
// Force resolution
await offlineDB.resolveConflict('message', 'msg-id')- Verify user is authenticated
- Check settings version
- Verify API endpoint
// Debug settings
const settings = await offlineDB.getSettings(userId)
console.log('Local settings:', settings)| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| IndexedDB | ✅ | ✅ | ✅ | ✅ |
| Service Workers | ✅ | ✅ | ✅ | ✅ |
| Background Sync | ✅ | ❌ | ❌ | ✅ |
| Periodic Sync | ✅ | ❌ | ❌ | ✅ |
Note: Background Sync gracefully degrades to manual sync on unsupported browsers.
-
Update imports:
// Old import { offlineDB } from '@/lib/offline/offline-storage' // New import { offlineDB } from '@/lib/offline/indexeddb'
-
Update queue methods:
// Old await queueStorage.add(message) // New await offlineDB.addToMessageQueue(message)
-
Update sync service:
// Old import { syncManager } from '@/lib/offline/sync-manager' // New import { syncService } from '@/lib/offline/sync-service'
- Queue Size: < 1000 items
- Sync Time: < 5 seconds for 100 messages
- IndexedDB Size: < 50MB recommended
- Sync Interval: 30 seconds default
- Retry Delay: 1s, 2s, 4s, 8s, 16s, 30s (exponential backoff)
- Data Encryption: Sensitive data is encrypted before storage
- API Authentication: All sync requests are authenticated
- Data Validation: Server validates all synced data
- Rate Limiting: Sync requests are rate-limited
- Conflict Logging: Conflicts are logged for audit
For issues or questions:
- GitHub Issues: nself-chat/issues
- Documentation: docs/