diff --git a/poc-sync-engine/src/SyncManager.ts b/poc-sync-engine/src/SyncManager.ts new file mode 100644 index 0000000..f2a25ba --- /dev/null +++ b/poc-sync-engine/src/SyncManager.ts @@ -0,0 +1,69 @@ +import { SyncQueue, SyncJob } from './SyncQueue'; + +export interface SyncHandler { + handle(job: SyncJob): Promise; +} + +export class SyncManager { + private queue: SyncQueue; + private handler: SyncHandler; + private isOnline: boolean = false; + private isProcessing: boolean = false; + + constructor(queue: SyncQueue, handler: SyncHandler) { + this.queue = queue; + this.handler = handler; + } + + public updateNetworkStatus(online: boolean) { + console.log(`[Network] Status changed to ${online ? 'ONLINE' : 'OFFLINE'}`); + this.isOnline = online; + if (this.isOnline) { + this.drainQueue(); + } + } + + public getQueueLength(): number { + return this.queue.getLength(); + } + + public async drainQueue(): Promise { + if (this.isProcessing || !this.isOnline || this.queue.getLength() === 0) { + return; + } + + this.isProcessing = true; + + while (this.queue.getLength() > 0 && this.isOnline) { + const job = this.queue.peek(); + if (!job) break; + + try { + console.log(`[SyncManager] Attempting to sync job ${job.id} (${job.method} ${job.url})`); + + await this.handler.handle(job); + + console.log(`[SyncManager] Successfully synced job ${job.id}`); + this.queue.dequeue(); + } catch (error) { + console.error(`[SyncManager] Sync failed for job ${job.id}`); + + if (!this.isOnline) { + break; + } + + if (job.retryCount >= 5) { + console.error(`[SyncManager] Max retries reached for job ${job.id}. Dropping or moving to DLQ.`); + this.queue.dequeue(); + } else { + this.queue.requeue(job); + const backoffTime = Math.pow(2, job.retryCount) * 1000; + console.log(`[SyncManager] Backing off for ${backoffTime}ms before next attempt`); + await new Promise(resolve => setTimeout(resolve, backoffTime)); + } + } + } + + this.isProcessing = false; + } +} diff --git a/poc-sync-engine/src/SyncQueue.ts b/poc-sync-engine/src/SyncQueue.ts new file mode 100644 index 0000000..7f6b3bb --- /dev/null +++ b/poc-sync-engine/src/SyncQueue.ts @@ -0,0 +1,53 @@ +export interface SyncJob { + id: string; + url: string; + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + payload: T; + timestamp: number; + retryCount: number; +} + +export class SyncQueue { + private queue: SyncJob[] = []; + + constructor() { + this.loadFromStorage(); + } + + public enqueue(job: Omit, 'id' | 'timestamp' | 'retryCount'>): void { + const newJob: SyncJob = { + ...job, + id: Math.random().toString(36).substring(2, 9), + timestamp: Date.now(), + retryCount: 0, + }; + this.queue.push(newJob); + this.persistToStorage(); + } + + public peek(): SyncJob | undefined { + return this.queue[0]; + } + + public dequeue(): SyncJob | undefined { + const job = this.queue.shift(); + this.persistToStorage(); + return job; + } + + public requeue(job: SyncJob): void { + job.retryCount += 1; + this.persistToStorage(); + } + + public getLength(): number { + return this.queue.length; + } + + private persistToStorage() { + } + + private loadFromStorage() { + this.queue = []; + } +} diff --git a/poc-sync-engine/tests/SyncManager.test.ts b/poc-sync-engine/tests/SyncManager.test.ts new file mode 100644 index 0000000..19575be --- /dev/null +++ b/poc-sync-engine/tests/SyncManager.test.ts @@ -0,0 +1,97 @@ +import { SyncQueue } from '../src/SyncQueue'; +import { SyncManager, SyncHandler } from '../src/SyncManager'; + +describe('Offline-First Sync Engine', () => { + let queue: SyncQueue; + let manager: SyncManager; + let mockHandler: jest.Mocked; + + beforeEach(() => { + queue = new SyncQueue(); + mockHandler = { + handle: jest.fn().mockResolvedValue(undefined), + }; + manager = new SyncManager(queue, mockHandler); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should enqueue mutations successfully', () => { + queue.enqueue({ + url: '/api/resource/LMS Assignment Submission', + method: 'POST', + payload: { answer: 'A', assignment_id: '123' }, + }); + + expect(queue.getLength()).toBe(1); + expect(queue.peek()?.url).toBe('/api/resource/LMS Assignment Submission'); + }); + + test('should not process queue when offline', async () => { + queue.enqueue({ url: '/api/resource/Course', method: 'POST', payload: {} }); + + manager.updateNetworkStatus(false); + await manager.drainQueue(); + + expect(queue.getLength()).toBe(1); + expect(mockHandler.handle).not.toHaveBeenCalled(); + }); + + test('should drain queue when online', async () => { + mockHandler.handle.mockResolvedValue(undefined); + + queue.enqueue({ url: '/api/resource/A', method: 'POST', payload: {} }); + queue.enqueue({ url: '/api/resource/B', method: 'POST', payload: {} }); + + manager.updateNetworkStatus(true); + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(queue.getLength()).toBe(0); + expect(mockHandler.handle).toHaveBeenCalledTimes(2); + }); + + test('should support and preserve typed payloads', () => { + interface AssignmentPayload { + answer: string; + assignment_id: string; + } + const typedQueue = new SyncQueue(); + typedQueue.enqueue({ + url: '/api/resource/LMS Assignment Submission', + method: 'POST', + payload: { answer: 'B', assignment_id: '456' }, + }); + + const job = typedQueue.peek(); + expect(job).toBeDefined(); + if (job) { + const payload: AssignmentPayload = job.payload; + expect(payload.answer).toBe('B'); + expect(payload.assignment_id).toBe('456'); + } + }); + + test('should retry on handler failure', async () => { + jest.useFakeTimers(); + mockHandler.handle + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValue(undefined); + + queue.enqueue({ url: '/api/resource/A', method: 'POST', payload: {} }); + + manager.updateNetworkStatus(true); + + await Promise.resolve(); // trigger initial attempt and failure + await jest.advanceTimersByTimeAsync(2000); // trigger retry attempt + + expect(queue.getLength()).toBe(0); + expect(mockHandler.handle).toHaveBeenCalledTimes(2); + jest.useRealTimers(); + }); +});