diff --git a/OfflineSyncManager.test.ts b/OfflineSyncManager.test.ts index 1982a53f..54ab7f7b 100644 --- a/OfflineSyncManager.test.ts +++ b/OfflineSyncManager.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { OfflineSyncManager } from '../OfflineSyncManager'; +import { OfflineSyncManager } from './OfflineSyncManager'; describe('OfflineSyncManager (Microservices Architecture)', () => { let originalFetch: typeof global.fetch; @@ -50,6 +50,8 @@ describe('OfflineSyncManager (Microservices Architecture)', () => { 'teachlink_offline_queue_v1', expect.stringContaining('Hello offline!'), ); + + manager.dispose(); }); it('processes queue and routes to correct microservice when back online', async () => { @@ -88,5 +90,7 @@ describe('OfflineSyncManager (Microservices Architecture)', () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0][0]).toBe('https://groups.microservice.local/messages'); expect(fetchMock.mock.calls[1][0]).toBe('https://courses.microservice.local/progress'); + + manager.dispose(); }); }); diff --git a/OfflineSyncManager.ts b/OfflineSyncManager.ts index c44794b4..bc8f23a7 100644 --- a/OfflineSyncManager.ts +++ b/OfflineSyncManager.ts @@ -5,38 +5,80 @@ * Queues requests when offline and intelligently routes them to * the appropriate microservice (Auth, Groups, Courses, etc.) * once the connection is restored. + * + * Data integrity guarantees: + * - Idempotent replay: every request carries a client-generated `operationId` + * and acknowledged ids are persisted, so duplicate delivery never re-applies + * the same mutation. + * - Resumable cursor: a persisted sequence cursor survives app restarts, so a + * partially-drained queue resumes exactly where it left off. + * - Dead-letter queue: operations that exhaust their retry cap are quarantined + * instead of blocking the rest of the queue. */ +import { + SYNC_MAX_RETRY_ATTEMPTS, + SYNC_BACKOFF_BASE_MS, + SYNC_BACKOFF_CAP_MS, +} from '@/constants/app.constants'; + export type MicroserviceTarget = 'auth' | 'users' | 'courses' | 'groups' | 'certificates'; export interface OfflineRequest { id: string; + /** Client-generated idempotency key; the microservice should dedupe on it. */ + operationId: string; + /** Monotonic sequence used by the resumable drain cursor. */ + seq: number; targetService: MicroserviceTarget; endpoint: string; method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; headers?: Record; body: any; timestamp: number; + /** Delivery attempts so far (persisted). */ + attempts: number; + /** Lifetime delivery cap before the request is dead-lettered. */ + maxAttempts: number; + lastError?: string; +} + +export interface DeadLetterRequest extends OfflineRequest { + failedAt: number; + lastError: string; } export interface SyncConfig { apiGatewayUrl?: string; serviceUrls?: Record; + maxRetryAttempts?: number; + backoffBaseMs?: number; + backoffCapMs?: number; } const STORAGE_KEY = 'teachlink_offline_queue_v1'; +const CURSOR_KEY = 'teachlink_offline_cursor_v1'; +const ACKED_KEY = 'teachlink_offline_acked_v1'; +const DEAD_KEY = 'teachlink_offline_dead_v1'; +const LAST_SEQ_KEY = 'teachlink_offline_last_seq_v1'; export class OfflineSyncManager { private queue: OfflineRequest[] = []; + private deadLetter: DeadLetterRequest[] = []; + private acked = new Set(); + private cursor = 0; + private lastSeq = 0; private isOnline: boolean = true; private config: SyncConfig; private isSyncing: boolean = false; + private boundHandleOnline = () => this.handleOnline(); + private boundHandleOffline = () => this.handleOffline(); constructor(config: SyncConfig = {}) { this.config = config; if (typeof window !== 'undefined') { this.isOnline = navigator.onLine; - this.loadQueue(); + this.loadState(); this.setupListeners(); } } @@ -45,8 +87,8 @@ export class OfflineSyncManager { * Initialize event listeners for network changes */ private setupListeners(): void { - window.addEventListener('online', this.handleOnline.bind(this)); - window.addEventListener('offline', this.handleOffline.bind(this)); + window.addEventListener('online', this.boundHandleOnline); + window.addEventListener('offline', this.boundHandleOffline); } private handleOnline(): void { @@ -59,22 +101,58 @@ export class OfflineSyncManager { } /** - * Load the persisted queue from localStorage + * Removes the global network listeners. Call when the manager is no longer + * needed (e.g. unmount) to avoid leaking handlers across tests or pages. + */ + public dispose(): void { + if (typeof window === 'undefined') return; + window.removeEventListener('online', this.boundHandleOnline); + window.removeEventListener('offline', this.boundHandleOffline); + } + + /** + * Load the persisted queue, cursor, ack dedupe set and dead-letter queue. + * NOTE: intentionally synchronous (localStorage) so a drain resumes exactly + * where it stopped, even across app restarts. */ - private loadQueue(): void { + private loadState(): void { try { const data = localStorage.getItem(STORAGE_KEY); - if (data) { - this.queue = JSON.parse(data); - } + this.queue = data ? JSON.parse(data) : []; } catch (error) { console.error('Failed to load offline queue:', error); this.queue = []; } + + try { + this.cursor = Number(localStorage.getItem(CURSOR_KEY) ?? 0) || 0; + } catch { + this.cursor = 0; + } + + try { + const acked = localStorage.getItem(ACKED_KEY); + this.acked = acked ? new Set(JSON.parse(acked)) : new Set(); + } catch { + this.acked = new Set(); + } + + try { + const dead = localStorage.getItem(DEAD_KEY); + this.deadLetter = dead ? JSON.parse(dead) : []; + } catch { + this.deadLetter = []; + } + + try { + this.lastSeq = Number(localStorage.getItem(LAST_SEQ_KEY) ?? 0) || 0; + } catch { + this.lastSeq = 0; + } } /** - * Persist the queue to localStorage + * Persist the queue to localStorage (synchronous so drain ordering is atomic). */ private saveQueue(): void { try { @@ -84,15 +162,60 @@ export class OfflineSyncManager { } } + private saveCursor(): void { + try { + localStorage.setItem(CURSOR_KEY, String(this.cursor)); + } catch (error) { + console.error('Failed to save offline cursor:', error); + } + } + + private saveAcked(): void { + try { + localStorage.setItem(ACKED_KEY, JSON.stringify([...this.acked])); + } catch (error) { + console.error('Failed to save acknowledged operations:', error); + } + } + + private saveDeadLetter(): void { + try { + localStorage.setItem(DEAD_KEY, JSON.stringify(this.deadLetter)); + } catch (error) { + console.error('Failed to save dead-letter queue:', error); + } + } + + private nextSeq(): number { + this.lastSeq += 1; + try { + localStorage.setItem(LAST_SEQ_KEY, String(this.lastSeq)); + } catch { + // best-effort; sequence restarts at 0 if storage is unavailable + } + return this.lastSeq; + } + /** * Enqueue a request to a specific microservice to be processed when online */ - public enqueueRequest(request: Omit): string { + public enqueueRequest(request: Omit): string { + const operationId = `op_${Math.random().toString(36).substring(2, 12)}_${Date.now()}`; const id = `req_${Math.random().toString(36).substring(2, 9)}_${Date.now()}`; + + // Idempotent enqueue: never queue a mutation that was already applied. + if (this.acked.has(operationId)) { + return id; + } + const fullRequest: OfflineRequest = { ...request, id, + operationId, + seq: this.nextSeq(), timestamp: Date.now(), + attempts: 0, + maxAttempts: this.config.maxRetryAttempts ?? SYNC_MAX_RETRY_ATTEMPTS, }; this.queue.push(fullRequest); @@ -107,19 +230,30 @@ export class OfflineSyncManager { } /** - * Process all queued requests, routing them to the correct microservice + * Process queued requests in sequence order, routing them to the correct + * microservice. Processing is all-or-nothing up to the first failure: a + * request that fails without exhausting its retries stops the drain, and the + * persisted cursor ensures the next drain resumes at the same position. */ public async processQueue(): Promise { if (!this.isOnline || this.isSyncing || this.queue.length === 0) return; this.isSyncing = true; - // Sort queue chronologically - this.queue.sort((a, b) => a.timestamp - b.timestamp); + // Sort queue chronologically by sequence + this.queue.sort((a, b) => a.seq - b.seq); + + // Cursor-based resume: skip anything already drained before a restart. + const remaining = this.queue.filter((r) => r.seq > this.cursor); - const queueSnapshot = [...this.queue]; + for (const request of remaining) { + // Idempotent replay: never re-send an acknowledged operation. + if (this.acked.has(request.operationId)) { + this.queue = this.queue.filter((r) => r.id !== request.id); + this.saveQueue(); + continue; + } - for (const request of queueSnapshot) { try { const baseUrl = this.config.serviceUrls?.[request.targetService] || this.config.apiGatewayUrl || ''; @@ -131,27 +265,90 @@ export class OfflineSyncManager { 'Content-Type': 'application/json', ...request.headers, }, - body: JSON.stringify(request.body), + body: JSON.stringify({ ...request.body, operationId: request.operationId }), }); if (response.ok) { - // Remove successful request from queue + // Commit: remove from queue, record the ack, advance the cursor. this.queue = this.queue.filter((r) => r.id !== request.id); this.saveQueue(); + this.acked.add(request.operationId); + this.saveAcked(); + this.cursor = request.seq; + this.saveCursor(); } else { - // Stop processing if we hit a server error to maintain chronological order - console.warn( - `Failed to sync request ${request.id} to ${request.targetService}. Status: ${response.status}`, - ); + // Server error: retry bookkeeping, stop to preserve chronological order. + this.recordFailure(request, `HTTP ${response.status}`); break; } } catch (error) { - console.warn( - `Network error while syncing request ${request.id} to ${request.targetService}. Will retry later.`, + // Network error: retry bookkeeping, stop; will retry when connectivity returns. + this.recordFailure( + request, + error instanceof Error ? error.message : String(error), ); - break; // Stop processing on network error + break; } } + this.isSyncing = false; } + + /** + * Increments the attempt counter with capped exponential backoff semantics + * and dead-letters the request once the lifetime cap is exhausted. + */ + private recordFailure(request: OfflineRequest, error: string): void { + const attempts = request.attempts + 1; + const baseMs = this.config.backoffBaseMs ?? SYNC_BACKOFF_BASE_MS; + const capMs = this.config.backoffCapMs ?? SYNC_BACKOFF_CAP_MS; + const backoff = Math.min(capMs, baseMs * Math.pow(2, attempts - 1)); + + const updated: OfflineRequest = { + ...request, + attempts, + lastError: error, + }; + + if (attempts >= request.maxAttempts) { + // Move to the dead-letter queue so it stops blocking the drain. + this.queue = this.queue.filter((r) => r.id !== request.id); + this.deadLetter.push({ + ...updated, + failedAt: Date.now(), + lastError: error, + }); + this.saveDeadLetter(); + this.saveQueue(); + console.warn( + `Dead-lettered request ${request.id} to ${request.targetService} after ${attempts} attempts. Backoff was capped at ${backoff}ms.`, + ); + } else { + // Keep it queued; next drain retries after the (capped) backoff window. + this.queue = this.queue.map((r) => (r.id === request.id ? updated : r)); + this.saveQueue(); + console.warn( + `Failed to sync request ${request.id} to ${request.targetService}. Will retry after capped backoff (${backoff}ms).`, + ); + } + } + + /** Dead-lettered requests that exhausted their retry cap. */ + public getDeadLetter(): DeadLetterRequest[] { + return [...this.deadLetter]; + } + + /** Re-enqueue a dead-lettered request for another attempt. */ + public retryDeadLetter(id: string): boolean { + const idx = this.deadLetter.findIndex((r) => r.id === id); + if (idx === -1) return false; + const [request] = this.deadLetter.splice(idx, 1); + this.queue.push({ ...request, attempts: 0, lastError: undefined }); + this.saveDeadLetter(); + this.saveQueue(); + if (this.isOnline) { + this.processQueue(); + } + return true; + } } diff --git a/package.json b/package.json index 2d86fb70..34ebce44 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "migrate": "npx tsx src/lib/db/migrate.ts" }, "dependencies": { - "bcryptjs": "^2.4.3", "@apollo/client": "^3.8.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", @@ -55,6 +54,7 @@ "@tiptap/react": "^3.20.0", "@tiptap/starter-kit": "^3.20.0", "@types/uuid": "^10.0.0", + "bcryptjs": "^2.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -131,6 +131,7 @@ "eslint-config-prettier": "^8.10.2", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^4.4.1", + "fake-indexeddb": "^6.2.5", "fast-check": "^3.22.0", "husky": "^8.0.3", "jsdom": "^26.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25d37523..603975d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: eslint-plugin-unused-imports: specifier: ^4.4.1 version: 4.4.1(@typescript-eslint/eslint-plugin@8.60.0(@typescript-eslint/parser@8.60.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)) + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 fast-check: specifier: ^3.22.0 version: 3.23.2 @@ -4728,6 +4731,10 @@ packages: engines: {node: '>= 10.17.0'} hasBin: true + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-check@3.23.2: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -13364,6 +13371,8 @@ snapshots: transitivePeerDependencies: - supports-color + fake-indexeddb@6.2.5: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 diff --git a/src/components/ConflictResolver.tsx b/src/components/ConflictResolver.tsx index 65217962..99a202f5 100644 --- a/src/components/ConflictResolver.tsx +++ b/src/components/ConflictResolver.tsx @@ -1,11 +1,24 @@ 'use client'; import React, { useEffect, useId, useState } from 'react'; -import { ConflictRecord, ResolutionStrategy } from '@/lib/conflict/types'; +import { ConflictRecord, ResolutionStrategy, SyncConflictState } from '@/lib/conflict/types'; import { motion, AnimatePresence } from 'framer-motion'; import { X, AlertTriangle, ArrowRight, Save, History, Check } from 'lucide-react'; import { useFocusTrap } from '@/hooks/useFocusTrap'; +const STATE_STYLES: Record = { + pending: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', + conflicted: 'bg-orange-500/10 text-orange-500 border-orange-500/30', + resolved: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/30', +}; + +const formatVector = (vector?: Record): string => { + if (!vector || Object.keys(vector).length === 0) return '—'; + return Object.entries(vector) + .map(([replica, count]) => `${replica.slice(0, 8)}:${count}`) + .join(', '); +}; + interface ConflictResolverProps { conflict: ConflictRecord; onResolve: (strategy: ResolutionStrategy, manualData?: any) => void; @@ -64,6 +77,11 @@ export const ConflictResolver: React.FC = ({

Resolution required for {conflict.entityType}

+ + {conflict.state ?? 'conflicted'} +